Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit 45939b30e63f53148148d3b21d9f47abf8a4bd45


Parents : b40fa56
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Date : 2026-06-03T16:24:17-05:00

feat(ai): Add App Functions for system AI integration (#5585)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Changes

37 files changed, 4471 insertions(+), 8 deletions(-)


Diff

diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index f6884c99d5..1ddfce32a1 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -68,6 +68,22 @@ analytics_notice
analytics_okay
analytics_platforms
any
+### APP ###
+app_functions_get_channel_info
+app_functions_get_device_status
+app_functions_get_mesh_metrics
+app_functions_get_mesh_status
+app_functions_get_node_details
+app_functions_get_node_list
+app_functions_get_recent_messages
+app_functions_get_unread_summary
+app_functions_master_summary
+app_functions_master_toggle
+app_functions_read_section
+app_functions_send_message
+app_functions_settings
+app_functions_settings_summary
+app_functions_write_section
app_notifications
app_settings
app_too_old

diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 397641364f..25dc8e5885 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -32,6 +32,7 @@ plugins {
alias(libs.plugins.secrets)
id("meshtastic.aboutlibraries")
id("dev.mokkery")
+ alias(libs.plugins.devtools.ksp)
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
@@ -178,6 +179,8 @@ secrets {
propertiesFileName = "secrets.properties"
}
+ksp { arg("appfunctions:aggregateAppFunctions", "true") }
+
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
variant.flavorName?.let { flavor -> variant.applicationId.set("com.geeksville.mesh.$flavor.debug") }
@@ -283,6 +286,10 @@ dependencies {
googleImplementation(libs.firebase.ai.ondevice)
googleImplementation(libs.mlkit.translate)
+ googleImplementation(libs.androidx.appfunctions)
+ googleImplementation(libs.androidx.appfunctions.service)
+ add("kspGoogle", libs.androidx.appfunctions.compiler)
+
fdroidImplementation(libs.osmdroid.android)
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
fdroidImplementation(libs.osmbonuspack)

diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
index 6e797e9520..a659cc5b48 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -17,6 +17,12 @@
package org.meshtastic.app.di
import org.koin.core.annotation.Module
+import org.koin.core.annotation.Named
+import org.koin.core.annotation.Single
@Module(includes = [FDroidNetworkModule::class, FdroidAiModule::class])
-class FlavorModule
+class FlavorModule {
+ @Single
+ @Named("googleServicesAvailable")
+ fun googleServicesAvailable(): Boolean = false
+}

diff --git a/androidApp/src/google/AndroidManifest.xml b/androidApp/src/google/AndroidManifest.xml
index c4138cb0bd..234f47eba3 100644
--- a/androidApp/src/google/AndroidManifest.xml
+++ b/androidApp/src/google/AndroidManifest.xml
@@ -16,12 +16,18 @@
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
-<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools">
- <application>
+ <application
+ android:name="org.meshtastic.app.GoogleMeshUtilApplication"
+ tools:replace="android:name">
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
+ <property
+ android:name="android.app.appfunctions.app_metadata"
+ android:resource="@xml/app_metadata" />
</application>
</manifest>

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/GoogleMeshUtilApplication.kt b/androidApp/src/google/kotlin/org/meshtastic/app/GoogleMeshUtilApplication.kt
new file mode 100644
index 0000000000..9e9970235f
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/GoogleMeshUtilApplication.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app
+
+import androidx.appfunctions.service.AppFunctionConfiguration
+import org.koin.java.KoinJavaComponent.getKoin
+import org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctions
+
+/**
+ * Google flavor Application subclass that configures App Functions.
+ *
+ * Registers a custom factory so the AppFunctions runtime can instantiate [MeshtasticAppFunctions] with its Koin-managed
+ * dependencies.
+ */
+class GoogleMeshUtilApplication :
+ MeshUtilApplication(),
+ AppFunctionConfiguration.Provider {
+
+ override val appFunctionConfiguration: AppFunctionConfiguration
+ get() =
+ AppFunctionConfiguration.Builder()
+ .addEnclosingClassFactory(MeshtasticAppFunctions::class.java) {
+ getKoin().get<MeshtasticAppFunctions>()
+ }
+ .build()
+}

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt
new file mode 100644
index 0000000000..e2892d4636
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt
@@ -0,0 +1,212 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.ai.appfunctions
+
+import androidx.appfunctions.AppFunctionIntValueConstraint
+import androidx.appfunctions.AppFunctionSerializable
+import androidx.appfunctions.AppFunctionStringValueConstraint
+
+/** Response returned when a message is successfully sent via the mesh network. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class SendMessageResponse(
+ /** The identifier assigned to the outgoing message. */
+ val messageId: Int,
+ /** The channel or destination the message was sent to. */
+ val channel: String,
+ /** The time the message was sent (epoch milliseconds). */
+ val timestamp: Long,
+)
+
+/** Response containing the current status of the Meshtastic mesh network. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class MeshStatusResponse(
+ /** The current radio connection state (e.g., CONNECTED, DISCONNECTED). */
+ @property:AppFunctionStringValueConstraint(enumValues = ["CONNECTED", "DISCONNECTED", "DEVICE_SLEEP"])
+ val connectionState: String,
+ /** The number of nodes currently online (heard within the last 2 hours). */
+ val onlineNodeCount: Int,
+ /** The total number of nodes known to the network. */
+ val totalNodeCount: Int,
+ /** The battery percentage of the connected Meshtastic device (1-100), or null if unavailable. */
+ val localBatteryLevel: Int?,
+ /** The display name of the local node, or null if not set. */
+ val localNodeName: String?,
+)
+
+/** Information about a single mesh node. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class NodeInfo(
+ /** The unique node identifier in Meshtastic hex format (e.g., !abc12345). */
+ val id: String,
+ /** The human-readable name of the node. */
+ val name: String,
+ /** The node's battery percentage (0-100), or null if unavailable. */
+ val batteryLevel: Int?,
+ /** The time this node was last heard from (epoch milliseconds). */
+ val lastHeard: Long,
+ /** Whether this node is currently considered online. */
+ val isOnline: Boolean,
+)
+
+/** Response containing a list of nodes visible on the mesh network. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetNodeListResponse(
+ /** List of nodes sorted by most recently heard first. */
+ val nodes: List<NodeInfo>,
+)
+
+/** Information about a single mesh channel. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class ChannelInfo(
+ /** The channel index (0-7). */
+ @property:AppFunctionIntValueConstraint(enumValues = [0, 1, 2, 3, 4, 5, 6, 7]) val index: Int,
+ /** The human-readable name of the channel. */
+ val name: String,
+ /** Whether this is the primary/default channel. */
+ val isPrimary: Boolean,
+ /** Whether uplink is enabled for this channel. */
+ val uplinkEnabled: Boolean,
+ /** Whether downlink is enabled for this channel. */
+ val downlinkEnabled: Boolean,
+)
+
+/** Response containing the list of available mesh channels. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetChannelInfoResponse(
+ /** List of all configured channels. */
+ val channels: List<ChannelInfo>,
+)
+
+/** Response containing the status of the local Meshtastic device. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetDeviceStatusResponse(
+ /** The hardware model of the device (e.g., "Meshtastic nRF52840"). */
+ val model: String,
+ /** The firmware version string. */
+ val firmwareVersion: String,
+ /** The device battery percentage (0-100), or null if not battery-powered. */
+ val batteryLevel: Int?,
+ /** The charging state (CHARGING, NOT_CHARGING, or UNKNOWN). */
+ @property:AppFunctionStringValueConstraint(enumValues = ["CHARGING", "NOT_CHARGING", "UNKNOWN"])
+ val chargingStatus: String,
+ /** The display name of the device, or null if not set. */
+ val deviceName: String?,
+ /** Whether the radio is currently active and connected. */
+ val isActive: Boolean,
+)
+
+/** Response containing detailed telemetry for a specific mesh node. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetNodeDetailsResponse(
+ /** Node ID in hex format (e.g., "!abc12345"). */
+ val id: String,
+ /** User ID string for this node. */
+ val userId: String,
+ /** Display name of the node. */
+ val name: String,
+ /** Battery percentage (0-100), or null if unavailable. */
+ val batteryLevel: Int?,
+ /** Supply voltage in volts, or null if unavailable. */
+ val voltage: Float?,
+ /** Hardware model string. */
+ val hardwareModel: String,
+ /** Firmware version string. */
+ val firmwareVersion: String,
+ /** Signal-to-noise ratio of strongest signal. */
+ val snr: Float,
+ /** Received signal strength indicator in dB. */
+ val rssi: Int,
+ /** Number of hops away from local node (-1 if unknown). */
+ val hopsAway: Int,
+ /** Channel index this node is on. */
+ val channel: Int,
+ /** Last heard timestamp (milliseconds since epoch). */
+ val lastHeard: Long,
+ /** User role or device type. */
+ val userRole: String,
+ /** Whether the user is licensed. */
+ val isLicensed: Boolean,
+ /** Latitude in degrees, or null if unknown. */
+ val latitude: Double?,
+ /** Longitude in degrees, or null if unknown. */
+ val longitude: Double?,
+)
+
+/** Response containing aggregate mesh network metrics. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetMeshMetricsResponse(
+ /** Total number of known nodes. */
+ val totalNodeCount: Int,
+ /** Number of nodes currently online. */
+ val onlineNodeCount: Int,
+ /** Average battery level across mesh, or null if no data. */
+ val averageBatteryLevel: Int?,
+ /** Estimated health score (0-100). */
+ val meshHealthScore: Int,
+ /** Timestamp of most recent packet (ms since epoch). */
+ val mostRecentPacketTime: Long,
+ /** Mesh uptime in seconds. */
+ val meshUptimeSeconds: Long,
+ /** Channel utilization percentage, or null if unavailable. */
+ val channelUtilizationPercent: Int?,
+)
+
+/** Response containing recent messages from the mesh network. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetRecentMessagesResponse(
+ /** List of recent messages ordered by most recent first. */
+ val messages: List<MessageInfo>,
+)
+
+/** Information about a single mesh message. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class MessageInfo(
+ /** Display name of the message sender. */
+ val senderName: String,
+ /** The message text content. */
+ val text: String,
+ /** Name of the channel or contact the message belongs to. */
+ val contactName: String,
+ /** Timestamp when the message was received (ms since epoch). */
+ val receivedTime: Long,
+ /** True if this message was sent by the local user. */
+ val fromLocal: Boolean,
+ /** True if this message has been read by the user. */
+ val read: Boolean,
+)
+
+/** Response containing a summary of unread messages across all contacts. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetUnreadSummaryResponse(
+ /** Total number of unread messages across all non-muted contacts. */
+ val totalUnreadCount: Int,
+ /** Per-contact breakdown of unread messages, sorted by most recent. */
+ val contacts: List<ContactUnreadInfo>,
+)
+
+/** Unread message details for a single contact or channel. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class ContactUnreadInfo(
+ /** Display name of the contact or channel. */
+ val name: String,
+ /** Number of unread messages from this contact. */
+ val unreadCount: Int,
+ /** Preview text of the most recent message (up to 100 chars), or null if unavailable. */
+ val lastMessagePreview: String?,
+ /** Timestamp of the most recent message (ms since epoch), or null if unavailable. */
+ val lastMessageTime: Long?,
+)

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionStateSync.kt b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionStateSync.kt
new file mode 100644
index 0000000000..f252b30d64
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionStateSync.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.ai.appfunctions
+
+import android.content.Context
+import androidx.appfunctions.AppFunctionManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.AppFunctionsPrefs
+
+/**
+ * Observes [AppFunctionsPrefs] and synchronizes the enabled/disabled state of each AppFunction with the system via
+ * [AppFunctionManager].
+ *
+ * When the master toggle is off, all functions are disabled regardless of individual toggles.
+ */
+class AppFunctionStateSync(
+ private val context: Context,
+ private val prefs: AppFunctionsPrefs,
+ dispatchers: CoroutineDispatchers,
+) {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ init {
+ observeAndSync()
+ }
+
+ private fun observeAndSync() {
+ data class FunctionToggle(val id: String, val enabled: StateFlow<Boolean>)
+
+ val functions =
+ listOf(
+ FunctionToggle(SEND_MESSAGE_ID, prefs.sendMessageEnabled),
+ FunctionToggle(GET_MESH_STATUS_ID, prefs.getMeshStatusEnabled),
+ FunctionToggle(GET_NODE_LIST_ID, prefs.getNodeListEnabled),
+ FunctionToggle(GET_CHANNEL_INFO_ID, prefs.getChannelInfoEnabled),
+ FunctionToggle(GET_DEVICE_STATUS_ID, prefs.getDeviceStatusEnabled),
+ FunctionToggle(GET_NODE_DETAILS_ID, prefs.getNodeDetailsEnabled),
+ FunctionToggle(GET_MESH_METRICS_ID, prefs.getMeshMetricsEnabled),
+ FunctionToggle(GET_RECENT_MESSAGES_ID, prefs.getRecentMessagesEnabled),
+ FunctionToggle(GET_UNREAD_SUMMARY_ID, prefs.getUnreadSummaryEnabled),
+ )
+
+ // Combine master toggle with each individual toggle
+ combine(prefs.masterEnabled, combine(functions.map { it.enabled }) { it.toList() }) { master, toggles ->
+ functions.mapIndexed { index, fn -> fn.id to (master && toggles[index]) }
+ }
+ .onEach { states -> syncStates(states) }
+ .launchIn(scope)
+ }
+
+ private suspend fun syncStates(states: List<Pair<String, Boolean>>) {
+ val manager = AppFunctionManager.getInstance(context) ?: return
+
+ for ((functionId, enabled) in states) {
+ val state =
+ if (enabled) {
+ AppFunctionManager.APP_FUNCTION_STATE_ENABLED
+ } else {
+ AppFunctionManager.APP_FUNCTION_STATE_DISABLED
+ }
+ try {
+ manager.setAppFunctionEnabled(functionId, state)
+ } catch (_: Exception) {
+ // Function may not be indexed yet (first launch)
+ }
+ }
+ }
+
+ companion object {
+ private const val CLASS_PREFIX = "org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctions#"
+
+ const val SEND_MESSAGE_ID = "${CLASS_PREFIX}sendMessage"
+ const val GET_MESH_STATUS_ID = "${CLASS_PREFIX}getMeshStatus"
+ const val GET_NODE_LIST_ID = "${CLASS_PREFIX}getNodeList"
+ const val GET_CHANNEL_INFO_ID = "${CLASS_PREFIX}getChannelInfo"
+ const val GET_DEVICE_STATUS_ID = "${CLASS_PREFIX}getDeviceStatus"
+ const val GET_NODE_DETAILS_ID = "${CLASS_PREFIX}getNodeDetails"
+ const val GET_MESH_METRICS_ID = "${CLASS_PREFIX}getMeshMetrics"
+ const val GET_RECENT_MESSAGES_ID = "${CLASS_PREFIX}getRecentMessages"
+ const val GET_UNREAD_SUMMARY_ID = "${CLASS_PREFIX}getUnreadSummary"
+ }
+}

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctions.kt b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctions.kt
new file mode 100644
index 0000000000..0d0c65aecd
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctions.kt
@@ -0,0 +1,417 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.ai.appfunctions
+
+import androidx.appfunctions.AppFunctionContext
+import androidx.appfunctions.AppFunctionElementNotFoundException
+import androidx.appfunctions.AppFunctionIntValueConstraint
+import androidx.appfunctions.AppFunctionInvalidArgumentException
+import androidx.appfunctions.AppFunctionNotSupportedException
+import androidx.appfunctions.service.AppFunction
+import kotlinx.coroutines.TimeoutCancellationException
+import org.meshtastic.core.data.ai.AiFunctionProvider
+import org.meshtastic.core.data.ai.SendMessageResult
+
+/**
+ * Exposes Meshtastic mesh networking capabilities to system AI assistants via the Android App Functions API. Functions
+ * declared here are discoverable by the system and can be invoked by AI agents such as Gemini.
+ */
+class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
+
+ /**
+ * Send a text message over the Meshtastic mesh radio network.
+ *
+ * Messages are transmitted to nearby mesh nodes using LoRa radio. The mesh network is ideal for off-grid
+ * communications where cellular service is unavailable.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @param text The message text to send (max 237 bytes).
+ * @param recipientName Optional name of a specific node to send a direct message to. If omitted, the message is
+ * broadcast to all nodes on the specified channel.
+ * @param channelName Optional channel name to broadcast on. If omitted, uses the primary channel. Ignored when
+ * recipientName is specified.
+ * @return A [SendMessageResponse] with the message ID, channel, and timestamp.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun sendMessage(
+ context: AppFunctionContext,
+ text: String,
+ recipientName: String? = null,
+ channelName: String? = null,
+ ): SendMessageResponse {
+ val result =
+ try {
+ provider.sendMessage(text, recipientName, channelName)
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the mesh is connected and try again.",
+ )
+ }
+
+ return when (result) {
+ is SendMessageResult.Success ->
+ SendMessageResponse(
+ messageId = result.messageId,
+ channel = result.channel,
+ timestamp = result.timestamp,
+ )
+
+ is SendMessageResult.NotConnected -> throw AppFunctionNotSupportedException(result.message)
+
+ is SendMessageResult.AmbiguousName -> {
+ val names = result.candidates.joinToString()
+ throw AppFunctionInvalidArgumentException(
+ "Multiple nodes match that name: $names. Please be more specific.",
+ )
+ }
+
+ is SendMessageResult.InvalidArgument -> throw AppFunctionInvalidArgumentException(result.reason)
+
+ is SendMessageResult.RateLimited ->
+ throw AppFunctionInvalidArgumentException(
+ "Rate limit exceeded. Try again in ${result.retryAfterSeconds} seconds.",
+ )
+ }
+ }
+
+ /**
+ * Get the current status of the Meshtastic mesh network.
+ *
+ * Returns connection state, number of online nodes, total known nodes, the connected device's battery level, and
+ * the local node name.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @return A [MeshStatusResponse] with the current mesh network status.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getMeshStatus(context: AppFunctionContext): MeshStatusResponse {
+ val status =
+ try {
+ provider.getMeshStatus()
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the mesh is connected and try again.",
+ )
+ }
+
+ return MeshStatusResponse(
+ connectionState = status.connectionState,
+ onlineNodeCount = status.onlineNodeCount,
+ totalNodeCount = status.totalNodeCount,
+ localBatteryLevel = status.localBatteryLevel,
+ localNodeName = status.localNodeName,
+ )
+ }
+
+ /**
+ * List all nodes currently visible on the Meshtastic mesh network.
+ *
+ * Returns detailed information about each node including name, battery level, and last heard time. Nodes are sorted
+ * by most recently heard first.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @return A list of nodes with their current status and metrics.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getNodeList(context: AppFunctionContext): GetNodeListResponse {
+ val result =
+ try {
+ provider.getNodeList()
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the mesh is connected and try again.",
+ )
+ }
+
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetNodeListResult.Success ->
+ GetNodeListResponse(
+ nodes =
+ result.nodes.map {
+ NodeInfo(
+ id = it.id,
+ name = it.name,
+ batteryLevel = it.batteryLevel,
+ lastHeard = it.lastHeard,
+ isOnline = it.isOnline,
+ )
+ },
+ )
+
+ is org.meshtastic.core.data.ai.GetNodeListResult.NotConnected ->
+ throw AppFunctionNotSupportedException(result.message)
+
+ is org.meshtastic.core.data.ai.GetNodeListResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+
+ /**
+ * List all available Meshtastic mesh channels and their configurations.
+ *
+ * Returns details about each channel including name, index, primary status, and uplink/downlink settings.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @return A list of channels with their current configuration.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getChannelInfo(context: AppFunctionContext): GetChannelInfoResponse {
+ val result =
+ try {
+ provider.getChannelInfo()
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the mesh is connected and try again.",
+ )
+ }
+
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetChannelInfoResult.Success ->
+ GetChannelInfoResponse(
+ channels =
+ result.channels.map {
+ ChannelInfo(
+ index = it.index,
+ name = it.name,
+ isPrimary = it.isPrimary,
+ uplinkEnabled = it.uplinkEnabled,
+ downlinkEnabled = it.downlinkEnabled,
+ )
+ },
+ )
+
+ is org.meshtastic.core.data.ai.GetChannelInfoResult.NotConnected ->
+ throw AppFunctionNotSupportedException(result.message)
+
+ is org.meshtastic.core.data.ai.GetChannelInfoResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+
+ /**
+ * Get the status and metrics of the local Meshtastic radio device.
+ *
+ * Returns hardware model, firmware version, battery level, charging status, and current radio state.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @return Device status with current metrics and configuration.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getDeviceStatus(context: AppFunctionContext): GetDeviceStatusResponse {
+ val result =
+ try {
+ provider.getDeviceStatus()
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the device is initialized and try again.",
+ )
+ }
+
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetDeviceStatusResult.Success ->
+ GetDeviceStatusResponse(
+ model = result.device.model,
+ firmwareVersion = result.device.firmwareVersion,
+ batteryLevel = result.device.batteryLevel,
+ chargingStatus = result.device.chargingStatus,
+ deviceName = result.device.deviceName,
+ isActive = result.device.isActive,
+ )
+
+ is org.meshtastic.core.data.ai.GetDeviceStatusResult.NotAvailable ->
+ throw AppFunctionNotSupportedException(result.message)
+
+ is org.meshtastic.core.data.ai.GetDeviceStatusResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+
+ /**
+ * Retrieve detailed telemetry and status for a specific mesh node.
+ *
+ * Returns per-node metrics including battery level, signal strength, hardware model, and location data.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @param nodeId The target node ID (e.g., '!abc12345' or user ID).
+ * @return A [GetNodeDetailsResponse] with detailed node information.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getNodeDetails(context: AppFunctionContext, nodeId: String): GetNodeDetailsResponse {
+ val result =
+ try {
+ provider.getNodeDetails(nodeId)
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the mesh is connected and try again.",
+ )
+ }
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetNodeDetailsResult.Success ->
+ GetNodeDetailsResponse(
+ id = result.node.id,
+ userId = result.node.userId,
+ name = result.node.name,
+ batteryLevel = result.node.batteryLevel,
+ voltage = result.node.voltage,
+ hardwareModel = result.node.hardwareModel,
+ firmwareVersion = result.node.firmwareVersion,
+ snr = result.node.snr,
+ rssi = result.node.rssi,
+ hopsAway = result.node.hopsAway,
+ channel = result.node.channel,
+ lastHeard = result.node.lastHeard,
+ userRole = result.node.userRole,
+ isLicensed = result.node.isLicensed,
+ latitude = result.node.latitude,
+ longitude = result.node.longitude,
+ )
+
+ is org.meshtastic.core.data.ai.GetNodeDetailsResult.NotConnected ->
+ throw AppFunctionNotSupportedException(result.message)
+
+ is org.meshtastic.core.data.ai.GetNodeDetailsResult.NotFound ->
+ throw AppFunctionElementNotFoundException(result.message)
+
+ is org.meshtastic.core.data.ai.GetNodeDetailsResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+
+ /**
+ * Retrieve aggregate network metrics and statistics for the entire mesh.
+ *
+ * Returns mesh-wide analytics including total node count, online nodes, average battery level, and health score.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @return A [GetMeshMetricsResponse] with mesh-wide statistics.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getMeshMetrics(context: AppFunctionContext): GetMeshMetricsResponse {
+ val result =
+ try {
+ provider.getMeshMetrics()
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the mesh is connected and try again.",
+ )
+ }
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetMeshMetricsResult.Success ->
+ GetMeshMetricsResponse(
+ totalNodeCount = result.metrics.totalNodeCount,
+ onlineNodeCount = result.metrics.onlineNodeCount,
+ averageBatteryLevel = result.metrics.averageBatteryLevel,
+ meshHealthScore = result.metrics.meshHealthScore,
+ mostRecentPacketTime = result.metrics.mostRecentPacketTime,
+ meshUptimeSeconds = result.metrics.meshUptimeSeconds,
+ channelUtilizationPercent = result.metrics.channelUtilizationPercent,
+ )
+
+ is org.meshtastic.core.data.ai.GetMeshMetricsResult.NotConnected ->
+ throw AppFunctionNotSupportedException(result.message)
+
+ is org.meshtastic.core.data.ai.GetMeshMetricsResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+
+ /**
+ * Retrieve recent messages received over the Meshtastic mesh radio network.
+ *
+ * Returns a list of recent messages from the local message history. Messages are stored locally and do not require
+ * an active mesh connection. Useful for catching up on conversations or reviewing recent communications.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @param contactName Optional name of a node or channel to filter messages from. If omitted, returns messages from
+ * all contacts sorted by most recent.
+ * @param limit Maximum number of messages to return (1–50). Defaults to 20.
+ * @return A [GetRecentMessagesResponse] containing the list of recent messages.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getRecentMessages(
+ context: AppFunctionContext,
+ contactName: String? = null,
+ @AppFunctionIntValueConstraint(enumValues = [1, 5, 10, 20, 50])
+ limit: Int = AiFunctionProvider.DEFAULT_MESSAGE_LIMIT,
+ ): GetRecentMessagesResponse {
+ val result =
+ try {
+ provider.getRecentMessages(contactName, limit)
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException("Request timed out. Try again or reduce the message limit.")
+ }
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetRecentMessagesResult.Success ->
+ GetRecentMessagesResponse(
+ messages =
+ result.messages.map { msg ->
+ MessageInfo(
+ senderName = msg.senderName,
+ text = msg.text,
+ contactName = msg.contactName,
+ receivedTime = msg.receivedTime,
+ fromLocal = msg.fromLocal,
+ read = msg.read,
+ )
+ },
+ )
+
+ is org.meshtastic.core.data.ai.GetRecentMessagesResult.ContactNotFound ->
+ throw AppFunctionElementNotFoundException(result.message)
+
+ is org.meshtastic.core.data.ai.GetRecentMessagesResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+
+ /**
+ * Get a summary of unread messages across all Meshtastic mesh contacts.
+ *
+ * Returns the total unread count and a per-contact breakdown showing who sent unread messages, how many are unread,
+ * and a preview of the last message. Muted contacts are excluded. Does not require an active mesh connection.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @return A [GetUnreadSummaryResponse] with the total unread count and per-contact details.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getUnreadSummary(context: AppFunctionContext): GetUnreadSummaryResponse {
+ val result =
+ try {
+ provider.getUnreadSummary()
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException("Request timed out. Try again.")
+ }
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetUnreadSummaryResult.Success ->
+ GetUnreadSummaryResponse(
+ totalUnreadCount = result.summary.totalUnreadCount,
+ contacts =
+ result.summary.contacts.map { contact ->
+ ContactUnreadInfo(
+ name = contact.name,
+ unreadCount = contact.unreadCount,
+ lastMessagePreview = contact.lastMessagePreview,
+ lastMessageTime = contact.lastMessageTime,
+ )
+ },
+ )
+
+ is org.meshtastic.core.data.ai.GetUnreadSummaryResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+}

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/AppFunctionsModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/AppFunctionsModule.kt
new file mode 100644
index 0000000000..c632f14882
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/AppFunctionsModule.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.di
+
+import android.content.Context
+import org.koin.core.annotation.Module
+import org.koin.core.annotation.Named
+import org.koin.core.annotation.Single
+import org.meshtastic.app.ai.appfunctions.AppFunctionStateSync
+import org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctions
+import org.meshtastic.core.data.ai.AiFunctionProvider
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.AppFunctionsPrefs
+
+/** Provides AppFunctions integration for the Google flavor. */
+@Module
+class AppFunctionsModule {
+ @Single
+ fun meshtasticAppFunctions(provider: AiFunctionProvider): MeshtasticAppFunctions = MeshtasticAppFunctions(provider)
+
+ @Single(createdAtStart = true)
+ fun appFunctionStateSync(
+ context: Context,
+ prefs: AppFunctionsPrefs,
+ dispatchers: CoroutineDispatchers,
+ ): AppFunctionStateSync = AppFunctionStateSync(context, prefs, dispatchers)
+
+ @Single
+ @Named("googleServicesAvailable")
+ fun googleServicesAvailable(): Boolean = true
+}

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
index 20fe0bff6d..b0ecb874cc 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -19,5 +19,8 @@ package org.meshtastic.app.di
import org.koin.core.annotation.Module
import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule
-@Module(includes = [GoogleNetworkModule::class, GoogleMapsKoinModule::class, GoogleAiModule::class])
+@Module(
+ includes =
+ [GoogleNetworkModule::class, GoogleMapsKoinModule::class, GoogleAiModule::class, AppFunctionsModule::class],
+)
class FlavorModule

diff --git a/androidApp/src/google/res/xml/app_metadata.xml b/androidApp/src/google/res/xml/app_metadata.xml
new file mode 100644
index 0000000000..1ef23a2b2a
--- /dev/null
+++ b/androidApp/src/google/res/xml/app_metadata.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (c) 2026 Meshtastic LLC
+ ~
+ ~ This program is free software: you can redistribute it and/or modify
+ ~ it under the terms of the GNU General Public License as published by
+ ~ the Free Software Foundation, either version 3 of the License, or
+ ~ (at your option) any later version.
+ -->
+
+<AppFunctionAppMetadata xmlns:appfn="http://schemas.android.com/apk/androidx.appfunctions"
+ appfn:description="Meshtastic is a mesh radio networking app for off-grid communications using LoRa devices.
+
+Operational Patterns:
+- Use 'getMeshStatus' to verify the radio is connected before calling functions that require a live connection.
+- Use 'getNodeList' or 'getNodeDetails' to resolve node names and IDs before calling 'sendMessage' with a recipientName.
+- Use 'getChannelInfo' to discover available channel names before sending broadcast messages to non-primary channels.
+- 'getRecentMessages' and 'getUnreadSummary' work offline from local message history and do not require an active connection.
+- Prefer 'getUnreadSummary' for a quick overview, then 'getRecentMessages' with a contactName filter for details.
+
+Constraints:
+- Message text is limited to 237 bytes.
+- Node and channel name resolution is fuzzy (partial matches accepted).
+- Rate limit: 10 messages per 60 seconds via 'sendMessage'.
+- Message history limit: 1-50 messages per query (default 20)."
+ appfn:displayDescription="@string/app_description" />

diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml
index ba56e1790f..ef6e3c9f77 100644
--- a/androidApp/src/main/res/values/strings.xml
+++ b/androidApp/src/main/res/values/strings.xml
@@ -17,4 +17,5 @@
-->
<resources>
<string name="app_name">Meshtastic</string>
+ <string name="app_description">Off-grid mesh networking for secure, long-range communications via LoRa radio.</string>
</resources>

diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctionsTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctionsTest.kt
new file mode 100644
index 0000000000..edc7bb3479
--- /dev/null
+++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctionsTest.kt
@@ -0,0 +1,294 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.ai.appfunctions
+
+import androidx.appfunctions.AppFunctionContext
+import androidx.appfunctions.AppFunctionElementNotFoundException
+import androidx.appfunctions.AppFunctionInvalidArgumentException
+import androidx.appfunctions.AppFunctionNotSupportedException
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.everySuspend
+import dev.mokkery.mock
+import kotlinx.coroutines.test.runTest
+import org.junit.runner.RunWith
+import org.meshtastic.core.data.ai.AiFunctionProvider
+import org.meshtastic.core.data.ai.ChannelSummary
+import org.meshtastic.core.data.ai.ContactUnread
+import org.meshtastic.core.data.ai.DeviceStatus
+import org.meshtastic.core.data.ai.GetChannelInfoResult
+import org.meshtastic.core.data.ai.GetDeviceStatusResult
+import org.meshtastic.core.data.ai.GetMeshMetricsResult
+import org.meshtastic.core.data.ai.GetNodeDetailsResult
+import org.meshtastic.core.data.ai.GetNodeListResult
+import org.meshtastic.core.data.ai.GetRecentMessagesResult
+import org.meshtastic.core.data.ai.GetUnreadSummaryResult
+import org.meshtastic.core.data.ai.MeshMetrics
+import org.meshtastic.core.data.ai.MeshStatusResult
+import org.meshtastic.core.data.ai.MessageSummary
+import org.meshtastic.core.data.ai.NodeDetails
+import org.meshtastic.core.data.ai.NodeSummary
+import org.meshtastic.core.data.ai.SendMessageResult
+import org.meshtastic.core.data.ai.UnreadSummary
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35], application = android.app.Application::class)
+class MeshtasticAppFunctionsTest {
+
+ private val provider: AiFunctionProvider = mock(MockMode.autofill)
+ private val context: AppFunctionContext = mock(MockMode.autofill)
+ private val appFunctions = MeshtasticAppFunctions(provider)
+
+ @Test
+ fun sendMessage_success() = runTest {
+ everySuspend { provider.sendMessage("Hello", "Alice", null) } returns
+ SendMessageResult.Success(messageId = 1234, channel = "Primary", timestamp = 1700000000L)
+
+ val response = appFunctions.sendMessage(context, "Hello", "Alice", null)
+
+ assertEquals(1234, response.messageId)
+ assertEquals("Primary", response.channel)
+ assertEquals(1700000000L, response.timestamp)
+ }
+
+ @Test
+ fun sendMessage_ambiguousName() = runTest {
+ everySuspend { provider.sendMessage("Hello", "Al", null) } returns
+ SendMessageResult.AmbiguousName(listOf("Alice", "Albert"))
+
+ val exception =
+ assertFailsWith<AppFunctionInvalidArgumentException> {
+ appFunctions.sendMessage(context, "Hello", "Al", null)
+ }
+ assertTrue(exception.message!!.contains("Multiple nodes match that name"))
+ }
+
+ @Test
+ fun sendMessage_notConnected() = runTest {
+ everySuspend { provider.sendMessage("Hello", "Alice", null) } returns
+ SendMessageResult.NotConnected("Not connected")
+
+ assertFailsWith<AppFunctionNotSupportedException> { appFunctions.sendMessage(context, "Hello", "Alice", null) }
+ }
+
+ @Test
+ fun getMeshStatus_success() = runTest {
+ everySuspend { provider.getMeshStatus() } returns
+ MeshStatusResult(
+ connectionState = "CONNECTED",
+ onlineNodeCount = 5,
+ totalNodeCount = 10,
+ localBatteryLevel = 88,
+ localNodeName = "MyNode",
+ )
+
+ val response = appFunctions.getMeshStatus(context)
+
+ assertEquals("CONNECTED", response.connectionState)
+ assertEquals(5, response.onlineNodeCount)
+ assertEquals(10, response.totalNodeCount)
+ assertEquals(88, response.localBatteryLevel)
+ assertEquals("MyNode", response.localNodeName)
+ }
+
+ @Test
+ fun getNodeList_success() = runTest {
+ val nodes =
+ listOf(
+ NodeSummary(id = "1", name = "Alice", batteryLevel = 90, lastHeard = 1700000000L, isOnline = true),
+ NodeSummary(id = "2", name = "Bob", batteryLevel = null, lastHeard = 1600000000L, isOnline = false),
+ )
+ everySuspend { provider.getNodeList() } returns GetNodeListResult.Success(nodes)
+
+ val response = appFunctions.getNodeList(context)
+
+ assertEquals(2, response.nodes.size)
+ assertEquals("1", response.nodes[0].id)
+ assertEquals("Alice", response.nodes[0].name)
+ assertEquals(90, response.nodes[0].batteryLevel)
+ assertTrue(response.nodes[0].isOnline)
+ assertEquals("Bob", response.nodes[1].name)
+ assertEquals(null, response.nodes[1].batteryLevel)
+ }
+
+ @Test
+ fun getChannelInfo_success() = runTest {
+ val channels =
+ listOf(
+ ChannelSummary(
+ index = 0,
+ name = "Primary",
+ isPrimary = true,
+ uplinkEnabled = true,
+ downlinkEnabled = true,
+ ),
+ ChannelSummary(
+ index = 1,
+ name = "Secondary",
+ isPrimary = false,
+ uplinkEnabled = true,
+ downlinkEnabled = false,
+ ),
+ )
+ everySuspend { provider.getChannelInfo() } returns GetChannelInfoResult.Success(channels)
+
+ val response = appFunctions.getChannelInfo(context)
+
+ assertEquals(2, response.channels.size)
+ assertEquals("Primary", response.channels[0].name)
+ assertTrue(response.channels[0].isPrimary)
+ assertTrue(response.channels[0].uplinkEnabled)
+ assertEquals("Secondary", response.channels[1].name)
+ }
+
+ @Test
+ fun getDeviceStatus_success() = runTest {
+ val device =
+ DeviceStatus(
+ model = "T-Beam",
+ firmwareVersion = "2.3.15",
+ batteryLevel = 100,
+ chargingStatus = "NOT_CHARGING",
+ deviceName = "MyDevice",
+ isActive = true,
+ )
+ everySuspend { provider.getDeviceStatus() } returns GetDeviceStatusResult.Success(device)
+
+ val response = appFunctions.getDeviceStatus(context)
+
+ assertEquals("T-Beam", response.model)
+ assertEquals("2.3.15", response.firmwareVersion)
+ assertEquals(100, response.batteryLevel)
+ assertEquals("NOT_CHARGING", response.chargingStatus)
+ assertEquals("MyDevice", response.deviceName)
+ assertTrue(response.isActive)
+ }
+
+ @Test
+ fun getNodeDetails_success() = runTest {
+ val nodeDetails =
+ NodeDetails(
+ id = "!abc12345",
+ userId = "abc12345",
+ name = "TestNode",
+ batteryLevel = 75,
+ voltage = 3.9f,
+ hardwareModel = "T-Echo",
+ firmwareVersion = "2.3.15",
+ snr = 5.5f,
+ rssi = -90,
+ hopsAway = 1,
+ channel = 0,
+ lastHeard = 1700000000L,
+ userRole = "CLIENT",
+ isLicensed = false,
+ latitude = 45.0,
+ longitude = -90.0,
+ )
+ everySuspend { provider.getNodeDetails("!abc12345") } returns GetNodeDetailsResult.Success(nodeDetails)
+
+ val response = appFunctions.getNodeDetails(context, "!abc12345")
+
+ assertEquals("!abc12345", response.id)
+ assertEquals("TestNode", response.name)
+ assertEquals(75, response.batteryLevel)
+ assertEquals(3.9f, response.voltage)
+ assertEquals(45.0, response.latitude)
+ }
+
+ @Test
+ fun getNodeDetails_notFound() = runTest {
+ everySuspend { provider.getNodeDetails("!unknown") } returns GetNodeDetailsResult.NotFound("Node not found")
+
+ assertFailsWith<AppFunctionElementNotFoundException> { appFunctions.getNodeDetails(context, "!unknown") }
+ }
+
+ @Test
+ fun getMeshMetrics_success() = runTest {
+ val metrics =
+ MeshMetrics(
+ totalNodeCount = 12,
+ onlineNodeCount = 4,
+ averageBatteryLevel = 82,
+ meshHealthScore = 95,
+ mostRecentPacketTime = 1700000000L,
+ meshUptimeSeconds = 3600L,
+ channelUtilizationPercent = 5,
+ )
+ everySuspend { provider.getMeshMetrics() } returns GetMeshMetricsResult.Success(metrics)
+
+ val response = appFunctions.getMeshMetrics(context)
+
+ assertEquals(12, response.totalNodeCount)
+ assertEquals(4, response.onlineNodeCount)
+ assertEquals(82, response.averageBatteryLevel)
+ assertEquals(95, response.meshHealthScore)
+ }
+
+ @Test
+ fun getRecentMessages_success() = runTest {
+ val messages =
+ listOf(
+ MessageSummary(
+ senderName = "Alice",
+ text = "Hi",
+ contactName = "Alice",
+ receivedTime = 1700000000L,
+ fromLocal = false,
+ read = true,
+ ),
+ )
+ everySuspend { provider.getRecentMessages(null, 10) } returns GetRecentMessagesResult.Success(messages)
+
+ val response = appFunctions.getRecentMessages(context, null, 10)
+
+ assertEquals(1, response.messages.size)
+ assertEquals("Alice", response.messages[0].senderName)
+ assertEquals("Hi", response.messages[0].text)
+ }
+
+ @Test
+ fun getUnreadSummary_success() = runTest {
+ val summary =
+ UnreadSummary(
+ totalUnreadCount = 3,
+ contacts =
+ listOf(
+ ContactUnread(
+ name = "Alice",
+ unreadCount = 2,
+ lastMessagePreview = "Hi",
+ lastMessageTime = 1700000000L,
+ ),
+ ),
+ )
+ everySuspend { provider.getUnreadSummary() } returns GetUnreadSummaryResult.Success(summary)
+
+ val response = appFunctions.getUnreadSummary(context)
+
+ assertEquals(3, response.totalUnreadCount)
+ assertEquals(1, response.contacts.size)
+ assertEquals("Alice", response.contacts[0].name)
+ assertEquals(2, response.contacts[0].unreadCount)
+ }
+}

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt
new file mode 100644
index 0000000000..4829e53e18
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.ai
+
+/**
+ * Platform-agnostic contract defining operations that AI systems can invoke.
+ *
+ * This interface abstracts the app capabilities exposed to system AI assistants. On Android, the implementation is
+ * wired to AppFunctions. On other platforms, equivalent mechanisms (App Intents on iOS, MCP on Desktop) can implement
+ * this.
+ */
+interface AiFunctionProvider {
+
+ /**
+ * Send a text message over the mesh network.
+ *
+ * The destination is resolved by name using fuzzy matching β€” either a node name for direct messages or a channel
+ * name for broadcast. If both are null, the message is broadcast on the primary channel.
+ *
+ * @param text The message text to send.
+ * @param recipientName Optional node name for direct messages.
+ * @param channelName Optional channel name. Defaults to primary channel if omitted.
+ * @return Result indicating success or a typed failure reason.
+ */
+ suspend fun sendMessage(text: String, recipientName: String? = null, channelName: String? = null): SendMessageResult
+
+ /**
+ * Get the current mesh network status summary.
+ *
+ * @return Current connection state, node counts, and local device info.
+ */
+ suspend fun getMeshStatus(): MeshStatusResult
+
+ /**
+ * List all nodes currently visible on the mesh network.
+ *
+ * @return Success with list of nodes, or failure if not connected.
+ */
+ suspend fun getNodeList(): GetNodeListResult
+
+ /**
+ * List all available mesh channels and their configurations.
+ *
+ * @return Success with list of channels, or failure if not connected.
+ */
+ suspend fun getChannelInfo(): GetChannelInfoResult
+
+ /**
+ * Get status and metrics of the local mesh radio device.
+ *
+ * @return Success with device status, or failure if device unavailable.
+ */
+ suspend fun getDeviceStatus(): GetDeviceStatusResult
+
+ /**
+ * Get detailed telemetry and status for a specific mesh node.
+ *
+ * @param nodeId The target node ID (in Meshtastic format: "!hex" or user ID).
+ * @return Success with node details, or failure if not connected or node not found.
+ */
+ suspend fun getNodeDetails(nodeId: String): GetNodeDetailsResult
+
+ /**
+ * Get aggregate network metrics and statistics for the entire mesh.
+ *
+ * @return Success with mesh metrics, or failure if not connected.
+ */
+ suspend fun getMeshMetrics(): GetMeshMetricsResult
+
+ /**
+ * Get recent messages from the mesh network.
+ *
+ * Messages are returned from the local cache β€” an active radio connection is not required.
+ *
+ * @param contactName Optional contact/channel name to filter by. Uses fuzzy matching.
+ * @param limit Maximum number of messages to return (default 20, max 50).
+ * @return Success with list of messages, or failure if contact not found.
+ */
+ suspend fun getRecentMessages(
+ contactName: String? = null,
+ limit: Int = DEFAULT_MESSAGE_LIMIT,
+ ): GetRecentMessagesResult
+
+ /**
+ * Get a summary of unread messages grouped by contact.
+ *
+ * Returns the total unread count and a per-contact breakdown with the last message preview. Muted contacts are
+ * excluded.
+ *
+ * @return Unread summary with per-contact breakdown.
+ */
+ suspend fun getUnreadSummary(): GetUnreadSummaryResult
+
+ companion object {
+ const val DEFAULT_MESSAGE_LIMIT = 20
+ const val MAX_MESSAGE_LIMIT = 50
+ }
+}

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt
new file mode 100644
index 0000000000..d5ffed7fc8
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt
@@ -0,0 +1,527 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.ai
+
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withTimeout
+import org.koin.core.annotation.Single
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.usecase.SendMessageUseCase
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Implementation of [AiFunctionProvider] that bridges AI function invocations to existing Meshtastic repositories and
+ * use cases.
+ */
+@Suppress("TooManyFunctions")
+@Single(binds = [AiFunctionProvider::class])
+class AiFunctionProviderImpl(
+ private val serviceRepository: ServiceRepository,
+ private val nodeRepository: NodeRepository,
+ private val radioConfigRepository: RadioConfigRepository,
+ private val packetRepository: PacketRepository,
+ private val sendMessageUseCase: SendMessageUseCase,
+ private val fuzzyNameResolver: FuzzyNameResolver,
+ private val rateLimiter: RateLimiter,
+ private val clock: Clock,
+) : AiFunctionProvider {
+
+ override suspend fun sendMessage(text: String, recipientName: String?, channelName: String?): SendMessageResult =
+ withTimeout(OPERATION_TIMEOUT) {
+ // Check connection
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
+ return@withTimeout SendMessageResult.NotConnected(
+ "Not connected to a Meshtastic radio. Please connect first.",
+ )
+ }
+
+ // Check rate limit
+ when (val rateResult = rateLimiter.tryAcquire()) {
+ is RateLimitResult.Permitted -> {
+ /* proceed */
+ }
+
+ is RateLimitResult.Limited -> {
+ return@withTimeout SendMessageResult.RateLimited(rateResult.retryAfterSeconds)
+ }
+ }
+
+ // Validate message length
+ val messageBytes = text.encodeToByteArray()
+ if (messageBytes.size > MAX_MESSAGE_LENGTH) {
+ return@withTimeout SendMessageResult.InvalidArgument(
+ "Message too long: ${messageBytes.size} bytes exceeds maximum of $MAX_MESSAGE_LENGTH bytes.",
+ )
+ }
+
+ // Resolve destination
+ val contactKey =
+ resolveContactKey(recipientName, channelName)
+ ?: return@withTimeout SendMessageResult.InvalidArgument("Could not resolve destination.")
+
+ // Handle ambiguous results from resolution
+ if (contactKey is ResolvedContact.Ambiguous) {
+ return@withTimeout SendMessageResult.AmbiguousName(contactKey.candidates)
+ }
+
+ val key = (contactKey as ResolvedContact.Resolved).contactKey
+
+ // Send via existing use case and capture the generated messageId
+ try {
+ val messageId = sendMessageUseCase.invoke(text, key)
+
+ SendMessageResult.Success(
+ messageId = messageId,
+ channel = contactKey.channelName,
+ timestamp = clock.now().toEpochMilliseconds(),
+ )
+ } catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
+ if (ex is CancellationException) throw ex
+ SendMessageResult.InvalidArgument("Failed to send message: ${ex.message}")
+ }
+ }
+
+ override suspend fun getMeshStatus(): MeshStatusResult = withTimeout(OPERATION_TIMEOUT) {
+ val connectionState = serviceRepository.connectionState.value
+ val onlineCount = nodeRepository.onlineNodeCount.first()
+ val totalCount = nodeRepository.totalNodeCount.first()
+ val ourNode = nodeRepository.ourNodeInfo.value
+ val batteryLevel = ourNode?.batteryLevel?.takeIf { it in 1..MAX_BATTERY_LEVEL }
+ val nodeName = ourNode?.user?.long_name?.takeIf { it.isNotBlank() }
+
+ MeshStatusResult(
+ connectionState = connectionState.name,
+ onlineNodeCount = onlineCount,
+ totalNodeCount = totalCount,
+ localBatteryLevel = batteryLevel,
+ localNodeName = nodeName,
+ )
+ }
+
+ @Suppress("ReturnCount", "TooGenericExceptionCaught")
+ override suspend fun getNodeList(): GetNodeListResult = withTimeout(OPERATION_TIMEOUT) {
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
+ return@withTimeout GetNodeListResult.NotConnected("Not connected to a Meshtastic radio.")
+ }
+
+ try {
+ val nodeMap = nodeRepository.nodeDBbyNum.first()
+ val nodes =
+ nodeMap.values.map { node ->
+ NodeSummary(
+ id = "!${node.num.toString(HEX_RADIX)}",
+ name = node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${node.num}",
+ batteryLevel = node.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
+ lastHeard = node.lastHeard.toLong() * MS_PER_SEC,
+ isOnline = node.isOnline,
+ )
+ }
+ GetNodeListResult.Success(nodes.sortedByDescending { it.lastHeard })
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetNodeListResult.Error("Failed to retrieve node list: ${ex.message}")
+ }
+ }
+
+ @Suppress("ReturnCount", "TooGenericExceptionCaught")
+ override suspend fun getChannelInfo(): GetChannelInfoResult = withTimeout(OPERATION_TIMEOUT) {
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
+ return@withTimeout GetChannelInfoResult.NotConnected("Not connected to a Meshtastic radio.")
+ }
+
+ try {
+ val channelSet = radioConfigRepository.channelSetFlow.first()
+ val channels =
+ channelSet.settings.mapIndexed { index, channel ->
+ ChannelSummary(
+ index = index,
+ name = channel.name.takeIf { it.isNotBlank() } ?: "Channel $index",
+ isPrimary = index == 0,
+ uplinkEnabled = channel.uplink_enabled,
+ downlinkEnabled = channel.downlink_enabled,
+ )
+ }
+ GetChannelInfoResult.Success(channels)
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetChannelInfoResult.Error("Failed to retrieve channel info: ${ex.message}")
+ }
+ }
+
+ @Suppress("ReturnCount", "TooGenericExceptionCaught")
+ override suspend fun getDeviceStatus(): GetDeviceStatusResult = withTimeout(OPERATION_TIMEOUT) {
+ try {
+ val ourNode =
+ nodeRepository.ourNodeInfo.value
+ ?: return@withTimeout GetDeviceStatusResult.NotAvailable("Device not yet initialized.")
+
+ val deviceStatus =
+ DeviceStatus(
+ model = ourNode.metadata?.hw_model?.name ?: "Unknown",
+ firmwareVersion = ourNode.metadata?.firmware_version ?: "Unknown",
+ batteryLevel = ourNode.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
+ chargingStatus = "UNKNOWN",
+ deviceName = ourNode.user.long_name.takeIf { it.isNotBlank() },
+ isActive = serviceRepository.connectionState.value == ConnectionState.Connected,
+ )
+ GetDeviceStatusResult.Success(deviceStatus)
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetDeviceStatusResult.Error("Failed to retrieve device status: ${ex.message}")
+ }
+ }
+
+ @Suppress("ReturnCount", "TooGenericExceptionCaught")
+ override suspend fun getNodeDetails(nodeId: String): GetNodeDetailsResult = withTimeout(OPERATION_TIMEOUT) {
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
+ return@withTimeout GetNodeDetailsResult.NotConnected("Not connected to a Meshtastic radio.")
+ }
+
+ try {
+ val node =
+ if (nodeId.startsWith("!")) {
+ // Hex format: extract number and search
+ val nodeNum = nodeId.drop(1).toInt(HEX_RADIX)
+ nodeRepository.nodeDBbyNum.first()[nodeNum]
+ } else {
+ // User ID format
+ nodeRepository.getNode(nodeId)
+ }
+
+ if (node == null) {
+ return@withTimeout GetNodeDetailsResult.NotFound("Node not found: $nodeId")
+ }
+
+ // Check if position is valid (both coords zero AND time zero indicates no position fix)
+ val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0 || node.position.time > 0
+
+ val details =
+ NodeDetails(
+ id = "!${node.num.toString(HEX_RADIX)}",
+ userId = node.user.id,
+ name = node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${node.num}",
+ batteryLevel = node.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
+ voltage = node.deviceMetrics.voltage,
+ hardwareModel = node.metadata?.hw_model?.name ?: "Unknown",
+ firmwareVersion = node.metadata?.firmware_version ?: "Unknown",
+ snr = node.snr,
+ rssi = node.rssi,
+ hopsAway = node.hopsAway,
+ channel = node.channel,
+ lastHeard = node.lastHeard.toLong() * MS_PER_SEC,
+ userRole = node.user.role.name,
+ isLicensed = node.user.is_licensed,
+ latitude = node.latitude.takeIf { hasValidPosition },
+ longitude = node.longitude.takeIf { hasValidPosition },
+ )
+ GetNodeDetailsResult.Success(details)
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetNodeDetailsResult.Error("Failed to retrieve node details: ${ex.message}")
+ }
+ }
+
+ @Suppress("ReturnCount", "TooGenericExceptionCaught")
+ override suspend fun getMeshMetrics(): GetMeshMetricsResult = withTimeout(OPERATION_TIMEOUT) {
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
+ return@withTimeout GetMeshMetricsResult.NotConnected("Not connected to a Meshtastic radio.")
+ }
+
+ try {
+ val totalCount = nodeRepository.totalNodeCount.first()
+ val onlineCount = nodeRepository.onlineNodeCount.first()
+
+ // Calculate average battery level
+ val nodeMap = nodeRepository.nodeDBbyNum.first()
+ val batteryLevels = nodeMap.values.mapNotNull { it.deviceMetrics.battery_level }
+ val avgBattery =
+ if (batteryLevels.isNotEmpty()) {
+ (batteryLevels.sum() / batteryLevels.size).coerceIn(0, MAX_BATTERY_LEVEL)
+ } else {
+ null
+ }
+
+ // Mesh health score: 0-100 based on online ratio and recent activity
+ val healthScore =
+ when {
+ totalCount == 0 -> 0
+ onlineCount == 0 -> HEALTH_SCORE_DEGRADED
+ else -> (HEALTH_SCORE_BASE + (HEALTH_SCORE_ONLINE_RATIO * onlineCount) / totalCount).toInt()
+ }
+
+ // Find most recent packet: max lastHeard across all nodes (convert seconds to ms)
+ val mostRecentPacketTimeMs =
+ nodeMap.values.maxOfOrNull { it.lastHeard }?.takeIf { it > 0 }?.toLong()?.times(MS_PER_SEC)
+ ?: clock.now().toEpochMilliseconds()
+
+ // Get local device uptime from its DeviceMetrics (node #0 is typically the local device)
+ val localNode = nodeMap.values.find { it.num == 0 } ?: nodeMap.values.firstOrNull()
+ val meshUptimeSeconds = localNode?.deviceMetrics?.uptime_seconds?.toLong() ?: 0L
+
+ val metrics =
+ MeshMetrics(
+ totalNodeCount = totalCount,
+ onlineNodeCount = onlineCount,
+ averageBatteryLevel = avgBattery,
+ meshHealthScore = healthScore.coerceIn(0, HEALTH_SCORE_MAX),
+ mostRecentPacketTime = mostRecentPacketTimeMs,
+ meshUptimeSeconds = meshUptimeSeconds,
+ channelUtilizationPercent = null, // Could compute from radioConfigRepository if needed
+ )
+ GetMeshMetricsResult.Success(metrics)
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetMeshMetricsResult.Error("Failed to retrieve mesh metrics: ${ex.message}")
+ }
+ }
+
+ @Suppress("ReturnCount", "TooGenericExceptionCaught")
+ override suspend fun getRecentMessages(contactName: String?, limit: Int): GetRecentMessagesResult =
+ withTimeout(OPERATION_TIMEOUT) {
+ try {
+ val effectiveLimit = limit.coerceIn(1, AiFunctionProvider.MAX_MESSAGE_LIMIT)
+
+ // Resolve contact key if a name filter is provided
+ val contactKey =
+ if (contactName != null) {
+ resolveContactKeyForRead(contactName)
+ ?: return@withTimeout GetRecentMessagesResult.ContactNotFound(
+ "Contact not found: $contactName",
+ )
+ } else {
+ null
+ }
+
+ val messages =
+ if (contactKey != null) {
+ // Fetch messages from a specific contact
+ packetRepository
+ .getMessagesFrom(
+ contact = contactKey,
+ limit = effectiveLimit,
+ includeFiltered = false,
+ getNode = { userId -> nodeRepository.getNode(userId ?: "") },
+ )
+ .first()
+ } else {
+ // Fetch recent messages across all contacts
+ val contacts = packetRepository.getContacts().first()
+ contacts.keys
+ .flatMap { key ->
+ packetRepository
+ .getMessagesFrom(
+ contact = key,
+ limit = MESSAGES_PER_CONTACT,
+ includeFiltered = false,
+ getNode = { userId -> nodeRepository.getNode(userId ?: "") },
+ )
+ .first()
+ }
+ .sortedByDescending { it.receivedTime }
+ .take(effectiveLimit)
+ }
+
+ val channelSet = radioConfigRepository.channelSetFlow.first()
+ val summaries =
+ messages.map { msg ->
+ MessageSummary(
+ senderName = msg.node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${msg.node.num}",
+ text = msg.text,
+ contactName = resolveContactDisplayName(msg, channelSet),
+ receivedTime = msg.receivedTime,
+ fromLocal = msg.fromLocal,
+ read = msg.read,
+ )
+ }
+
+ GetRecentMessagesResult.Success(summaries)
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetRecentMessagesResult.Error("Failed to retrieve messages: ${ex.message}")
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ override suspend fun getUnreadSummary(): GetUnreadSummaryResult = withTimeout(OPERATION_TIMEOUT) {
+ try {
+ val contacts = packetRepository.getContacts().first()
+ val settings = packetRepository.getContactSettings().first()
+ val channelSet = radioConfigRepository.channelSetFlow.first()
+ val nodeMap = nodeRepository.nodeDBbyNum.first()
+
+ val nonMutedContacts = contacts.filter { (key, _) -> settings[key]?.isMuted != true }
+
+ val contactUnreads =
+ nonMutedContacts.mapNotNull { (contactKey, lastPacket) ->
+ val unreadCount = packetRepository.getUnreadCount(contactKey)
+ if (unreadCount <= 0) return@mapNotNull null
+
+ val isBroadcast = lastPacket.to == DataPacket.ID_BROADCAST
+ val displayName =
+ if (isBroadcast) {
+ val channelIndex = contactKey.firstOrNull()?.digitToIntOrNull() ?: 0
+ channelSet.settings.getOrNull(channelIndex)?.name?.ifBlank { "Channel $channelIndex" }
+ ?: "Channel $channelIndex"
+ } else {
+ val userId = lastPacket.from ?: ""
+ val node = nodeMap.values.find { it.user.id == userId }
+ node?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Unknown"
+ }
+
+ ContactUnread(
+ name = displayName,
+ unreadCount = unreadCount,
+ lastMessagePreview = lastPacket.text?.take(MESSAGE_PREVIEW_MAX_LENGTH),
+ lastMessageTime = lastPacket.time.takeIf { it > 0 },
+ )
+ }
+
+ val totalUnread = contactUnreads.sumOf { it.unreadCount }
+
+ GetUnreadSummaryResult.Success(
+ UnreadSummary(
+ totalUnreadCount = totalUnread,
+ contacts = contactUnreads.sortedByDescending { it.lastMessageTime },
+ ),
+ )
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetUnreadSummaryResult.Error("Failed to retrieve unread summary: ${ex.message}")
+ }
+ }
+
+ /**
+ * Resolve a contact name (node or channel) to a contact key for reading messages. Returns null if the name cannot
+ * be resolved.
+ */
+ @Suppress("ReturnCount")
+ private suspend fun resolveContactKeyForRead(name: String): String? {
+ // Try node name first
+ when (val nodeResult = fuzzyNameResolver.resolveNodeName(name)) {
+ is NodeNameResult.Found -> {
+ val channelIndex = DataPacket.PKC_CHANNEL_INDEX
+ return "${channelIndex}${nodeResult.userId}"
+ }
+
+ is NodeNameResult.Ambiguous -> return null
+
+ is NodeNameResult.NotFound -> {
+ /* fall through to channel */
+ }
+ }
+
+ // Try channel name
+ return when (val channelResult = fuzzyNameResolver.resolveChannelName(name)) {
+ is ChannelNameResult.Found -> "${channelResult.channelIndex}${DataPacket.ID_BROADCAST}"
+ is ChannelNameResult.Ambiguous -> null
+ is ChannelNameResult.NotFound -> null
+ }
+ }
+
+ private fun resolveContactDisplayName(
+ msg: org.meshtastic.core.model.Message,
+ channelSet: org.meshtastic.proto.ChannelSet,
+ ): String {
+ // For broadcast messages, use channel name
+ val channelIndex = msg.node.channel
+ return channelSet.settings.getOrNull(channelIndex)?.name?.ifBlank { "Channel $channelIndex" }
+ ?: "Channel $channelIndex"
+ }
+
+ @Suppress("ReturnCount")
+ private suspend fun resolveContactKey(recipientName: String?, channelName: String?): ResolvedContact? {
+ // Direct message to a specific node
+ if (recipientName != null) {
+ return when (val result = fuzzyNameResolver.resolveNodeName(recipientName)) {
+ is NodeNameResult.Found -> {
+ // DM contact key format: channel_index + nodeId
+ // For PKC DMs, use channel index 8; for legacy use no channel prefix
+ val channelIndex = DataPacket.PKC_CHANNEL_INDEX
+ ResolvedContact.Resolved(
+ contactKey = "${channelIndex}${result.userId}",
+ channelName = "DM to $recipientName",
+ )
+ }
+
+ is NodeNameResult.Ambiguous -> ResolvedContact.Ambiguous(result.candidates)
+
+ is NodeNameResult.NotFound -> {
+ return null
+ }
+ }
+ }
+
+ // Broadcast to a specific channel
+ if (channelName != null) {
+ return when (val result = fuzzyNameResolver.resolveChannelName(channelName)) {
+ is ChannelNameResult.Found ->
+ ResolvedContact.Resolved(
+ contactKey = "${result.channelIndex}${DataPacket.ID_BROADCAST}",
+ channelName = result.name,
+ )
+
+ is ChannelNameResult.Ambiguous -> ResolvedContact.Ambiguous(result.candidates)
+
+ is ChannelNameResult.NotFound -> null
+ }
+ }
+
+ // Default: broadcast on primary channel (index 0)
+ val channelSet = radioConfigRepository.channelSetFlow.first()
+ val primaryName = channelSet.settings.firstOrNull()?.name?.ifBlank { "Primary" } ?: "Primary"
+ return ResolvedContact.Resolved(contactKey = "0${DataPacket.ID_BROADCAST}", channelName = primaryName)
+ }
+
+ private sealed class ResolvedContact {
+ data class Resolved(val contactKey: String, val channelName: String) : ResolvedContact()
+
+ data class Ambiguous(val candidates: List<String>) : ResolvedContact()
+ }
+
+ companion object {
+ private val OPERATION_TIMEOUT = 5.seconds
+ private const val MAX_BATTERY_LEVEL = 100
+ private const val HEX_RADIX = 16
+ private const val MS_PER_SEC = 1000L
+ private const val HEALTH_SCORE_BASE = 50
+ private const val HEALTH_SCORE_ONLINE_RATIO = 50
+ private const val HEALTH_SCORE_DEGRADED = 10
+ private const val HEALTH_SCORE_MAX = 100
+ private const val MESSAGES_PER_CONTACT = 5
+ private const val MESSAGE_PREVIEW_MAX_LENGTH = 100
+
+ /** Standard Meshtastic message payload limit (bytes). */
+ const val MAX_MESSAGE_LENGTH = 237
+ }
+}
+
+/** Extension to get a display name for ConnectionState. */
+private val ConnectionState.name: String
+ get() =
+ when (this) {
+ ConnectionState.Connected -> "CONNECTED"
+ ConnectionState.Connecting -> "CONNECTING"
+ ConnectionState.Disconnected -> "DISCONNECTED"
+ ConnectionState.DeviceSleep -> "DEVICE_SLEEP"
+ }

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt
new file mode 100644
index 0000000000..87619c73e3
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt
@@ -0,0 +1,267 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.ai
+
+/** Result of a [AiFunctionProvider.sendMessage] invocation. */
+sealed class SendMessageResult {
+ /** Message was successfully queued for transmission. */
+ data class Success(val messageId: Int, val channel: String, val timestamp: Long) : SendMessageResult()
+
+ /** Device is not connected to a Meshtastic radio. */
+ data class NotConnected(val message: String) : SendMessageResult()
+
+ /** The provided name matched multiple candidates. */
+ data class AmbiguousName(val candidates: List<String>) : SendMessageResult()
+
+ /** An argument was invalid (e.g., message too long, name not found). */
+ data class InvalidArgument(val reason: String) : SendMessageResult()
+
+ /** Rate limit exceeded β€” too many AI-triggered sends in the time window. */
+ data class RateLimited(val retryAfterSeconds: Int) : SendMessageResult()
+}
+
+/** Result of a [AiFunctionProvider.getMeshStatus] invocation. */
+data class MeshStatusResult(
+ /** Current connection state (e.g., "CONNECTED", "DISCONNECTED"). */
+ val connectionState: String,
+ /** Number of nodes heard within the online threshold. */
+ val onlineNodeCount: Int,
+ /** Total number of nodes in the local database. */
+ val totalNodeCount: Int,
+ /** Local device battery level (0-100), or null if unavailable. */
+ val localBatteryLevel: Int?,
+ /** Display name of the local node, or null if not yet configured. */
+ val localNodeName: String?,
+)
+
+/** Result of a [AiFunctionProvider.getNodeList] invocation. */
+sealed class GetNodeListResult {
+ /** Successfully retrieved the list of visible mesh nodes. */
+ data class Success(val nodes: List<NodeSummary>) : GetNodeListResult()
+
+ /** Device is not connected to a Meshtastic radio. */
+ data class NotConnected(val message: String) : GetNodeListResult()
+
+ /** An error occurred retrieving the node list. */
+ data class Error(val reason: String) : GetNodeListResult()
+}
+
+/** Summary information for a single mesh node. */
+data class NodeSummary(
+ /** Node ID in Meshtastic hex format (e.g., "!abc12345"). */
+ val id: String,
+ /** Display name of the node. */
+ val name: String,
+ /** Battery level (0-100), or null if unavailable. */
+ val batteryLevel: Int?,
+ /** Last time this node was heard from (milliseconds since epoch). */
+ val lastHeard: Long,
+ /** Whether this node is currently considered online. */
+ val isOnline: Boolean,
+)
+
+/** Result of a [AiFunctionProvider.getChannelInfo] invocation. */
+sealed class GetChannelInfoResult {
+ /** Successfully retrieved the list of channels. */
+ data class Success(val channels: List<ChannelSummary>) : GetChannelInfoResult()
+
+ /** Device is not connected to a Meshtastic radio. */
+ data class NotConnected(val message: String) : GetChannelInfoResult()
+
+ /** An error occurred retrieving channel info. */
+ data class Error(val reason: String) : GetChannelInfoResult()
+}
+
+/** Summary information for a single mesh channel. */
+data class ChannelSummary(
+ /** Channel index (0-7). */
+ val index: Int,
+ /** Display name of the channel. */
+ val name: String,
+ /** Whether this is the primary/default channel. */
+ val isPrimary: Boolean,
+ /** Uplink enabled for this channel. */
+ val uplinkEnabled: Boolean,
+ /** Downlink enabled for this channel. */
+ val downlinkEnabled: Boolean,
+)
+
+/** Result of a [AiFunctionProvider.getDeviceStatus] invocation. */
+sealed class GetDeviceStatusResult {
+ /** Successfully retrieved device status. */
+ data class Success(val device: DeviceStatus) : GetDeviceStatusResult()
+
+ /** Device is not available or not connected. */
+ data class NotAvailable(val message: String) : GetDeviceStatusResult()
+
+ /** An error occurred retrieving device status. */
+ data class Error(val reason: String) : GetDeviceStatusResult()
+}
+
+/** Status and metrics of the local mesh radio device. */
+data class DeviceStatus(
+ /** Device model/hardware (e.g., "Meshtastic nRF52840"). */
+ val model: String,
+ /** Firmware version string. */
+ val firmwareVersion: String,
+ /** Battery level (0-100), or null if not battery-powered. */
+ val batteryLevel: Int?,
+ /** Charging status: "CHARGING", "NOT_CHARGING", or "UNKNOWN". */
+ val chargingStatus: String,
+ /** Display name of the device. */
+ val deviceName: String?,
+ /** Whether the radio is currently transmitting or receiving. */
+ val isActive: Boolean,
+)
+
+/** Result of a [AiFunctionProvider.getNodeDetails] invocation. */
+sealed class GetNodeDetailsResult {
+ /** Successfully retrieved node details. */
+ data class Success(val node: NodeDetails) : GetNodeDetailsResult()
+
+ /** Device is not connected to a Meshtastic radio. */
+ data class NotConnected(val message: String) : GetNodeDetailsResult()
+
+ /** Node with given ID not found. */
+ data class NotFound(val message: String) : GetNodeDetailsResult()
+
+ /** An error occurred retrieving node details. */
+ data class Error(val reason: String) : GetNodeDetailsResult()
+}
+
+/** Detailed telemetry and status for a specific node. */
+data class NodeDetails(
+ /** Node ID in Meshtastic hex format (e.g., "!abc12345"). */
+ val id: String,
+ /** User ID string for this node. */
+ val userId: String,
+ /** Display name of the node. */
+ val name: String,
+ /** Battery level (0-100), or null if unavailable. */
+ val batteryLevel: Int?,
+ /** Supply voltage in volts, or null if unavailable. */
+ val voltage: Float?,
+ /** Hardware model (e.g., "Meshtastic nRF52840"). */
+ val hardwareModel: String,
+ /** Firmware version string. */
+ val firmwareVersion: String,
+ /** Signal-to-noise ratio of the strongest received signal. */
+ val snr: Float,
+ /** Received signal strength indicator in dB. */
+ val rssi: Int,
+ /** Number of hops away from the local node (-1 if unknown). */
+ val hopsAway: Int,
+ /** Channel index this node is on. */
+ val channel: Int,
+ /** Last time this node was heard from (milliseconds since epoch). */
+ val lastHeard: Long,
+ /** User role or device type (e.g., "CLIENT", "REPEATER"). */
+ val userRole: String,
+ /** Whether user is licensed to operate this hardware. */
+ val isLicensed: Boolean,
+ /** Latitude (degrees), or null if not available. */
+ val latitude: Double?,
+ /** Longitude (degrees), or null if not available. */
+ val longitude: Double?,
+)
+
+/** Result of a [AiFunctionProvider.getMeshMetrics] invocation. */
+sealed class GetMeshMetricsResult {
+ /** Successfully retrieved mesh metrics. */
+ data class Success(val metrics: MeshMetrics) : GetMeshMetricsResult()
+
+ /** Device is not connected to a Meshtastic radio. */
+ data class NotConnected(val message: String) : GetMeshMetricsResult()
+
+ /** An error occurred retrieving mesh metrics. */
+ data class Error(val reason: String) : GetMeshMetricsResult()
+}
+
+/** Aggregate network metrics and statistics for the entire mesh. */
+data class MeshMetrics(
+ /** Total number of nodes known to this device. */
+ val totalNodeCount: Int,
+ /** Number of nodes that are currently online. */
+ val onlineNodeCount: Int,
+ /** Average battery level across all nodes, or null if unknown. */
+ val averageBatteryLevel: Int?,
+ /** Estimated mesh health score (0-100), based on connectivity and node activity. */
+ val meshHealthScore: Int,
+ /** Timestamp of the most recent packet received (milliseconds since epoch). */
+ val mostRecentPacketTime: Long,
+ /** Mesh uptime since local node startup (seconds). */
+ val meshUptimeSeconds: Long,
+ /** Estimated channel utilization percentage (0-100), or null if unavailable. */
+ val channelUtilizationPercent: Int?,
+)
+
+/** Result of a [AiFunctionProvider.getRecentMessages] invocation. */
+sealed class GetRecentMessagesResult {
+ /** Successfully retrieved recent messages. */
+ data class Success(val messages: List<MessageSummary>) : GetRecentMessagesResult()
+
+ /** The specified contact was not found via fuzzy matching. */
+ data class ContactNotFound(val message: String) : GetRecentMessagesResult()
+
+ /** An error occurred retrieving messages. */
+ data class Error(val reason: String) : GetRecentMessagesResult()
+}
+
+/** Summary of a single mesh message suitable for AI consumption. */
+data class MessageSummary(
+ /** Display name of the message sender. */
+ val senderName: String,
+ /** The message text content. */
+ val text: String,
+ /** Channel or contact name this message belongs to. */
+ val contactName: String,
+ /** When the message was received (milliseconds since epoch). */
+ val receivedTime: Long,
+ /** Whether this message was sent by the local user. */
+ val fromLocal: Boolean,
+ /** Whether this message has been read. */
+ val read: Boolean,
+)
+
+/** Result of a [AiFunctionProvider.getUnreadSummary] invocation. */
+sealed class GetUnreadSummaryResult {
+ /** Successfully retrieved unread summary. */
+ data class Success(val summary: UnreadSummary) : GetUnreadSummaryResult()
+
+ /** An error occurred retrieving unread summary. */
+ data class Error(val reason: String) : GetUnreadSummaryResult()
+}
+
+/** Unread message summary across all contacts. */
+data class UnreadSummary(
+ /** Total number of unread messages across all contacts. */
+ val totalUnreadCount: Int,
+ /** Per-contact breakdown of unread messages (excludes muted contacts). */
+ val contacts: List<ContactUnread>,
+)
+
+/** Unread info for a single contact or channel. */
+data class ContactUnread(
+ /** Display name of the contact or channel. */
+ val name: String,
+ /** Number of unread messages from this contact. */
+ val unreadCount: Int,
+ /** Preview of the last message text, or null if none. */
+ val lastMessagePreview: String?,
+ /** Timestamp of the last message (milliseconds since epoch), or null if none. */
+ val lastMessageTime: Long?,
+)

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt
new file mode 100644
index 0000000000..d809284f62
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt
@@ -0,0 +1,167 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.ai
+
+import kotlinx.coroutines.flow.first
+import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+
+/**
+ * Resolves fuzzy node and channel name queries to concrete identifiers.
+ *
+ * Uses longest-common-substring matching with a minimum threshold of 50% of the query length. Returns an error with
+ * candidate list if ambiguous.
+ */
+@Single
+class FuzzyNameResolver(
+ private val nodeRepository: NodeRepository,
+ private val radioConfigRepository: RadioConfigRepository,
+) {
+
+ /** Resolve a node name query to a node number and user ID. */
+ fun resolveNodeName(query: String): NodeNameResult {
+ val nodes = nodeRepository.nodeDBbyNum.value
+ val candidates =
+ nodes.values
+ .filter { it.user.long_name.isNotBlank() }
+ .map { NameCandidate(it.user.long_name, it.num, it.user.id) }
+
+ return matchName(query, candidates)
+ }
+
+ /**
+ * Resolve a channel name query to a channel index.
+ *
+ * Admin channels are excluded from resolution (NFR-001).
+ */
+ @Suppress("ReturnCount")
+ suspend fun resolveChannelName(query: String): ChannelNameResult {
+ val channelSet = radioConfigRepository.channelSetFlow.first()
+ val candidates =
+ channelSet.settings
+ .mapIndexed { index, settings -> IndexedChannel(settings.name, index) }
+ .filter { it.name.isNotBlank() }
+ // Exclude admin channels (convention: channel named "admin" is sensitive)
+ .filter { !it.name.equals("admin", ignoreCase = true) }
+
+ if (candidates.isEmpty()) return ChannelNameResult.NotFound
+
+ // Exact match first
+ candidates
+ .firstOrNull { it.name.equals(query, ignoreCase = true) }
+ ?.let {
+ return ChannelNameResult.Found(it.index, it.name)
+ }
+
+ // Fuzzy match
+ val scored =
+ candidates
+ .map { it to longestCommonSubstringLength(query.lowercase(), it.name.lowercase()) }
+ .filter { (_, score) -> score >= (query.length * MATCH_THRESHOLD).toInt().coerceAtLeast(1) }
+ .sortedByDescending { it.second }
+
+ return when {
+ scored.isEmpty() -> ChannelNameResult.NotFound
+ scored.size == 1 -> ChannelNameResult.Found(scored[0].first.index, scored[0].first.name)
+ scored[0].second > scored[1].second -> ChannelNameResult.Found(scored[0].first.index, scored[0].first.name)
+ else -> ChannelNameResult.Ambiguous(scored.map { it.first.name })
+ }
+ }
+
+ @Suppress("ReturnCount")
+ private fun matchName(query: String, candidates: List<NameCandidate>): NodeNameResult {
+ if (candidates.isEmpty()) return NodeNameResult.NotFound
+
+ // Exact match first (case-insensitive)
+ candidates
+ .firstOrNull { it.name.equals(query, ignoreCase = true) }
+ ?.let {
+ return NodeNameResult.Found(it.nodeNum, it.userId)
+ }
+
+ // Fuzzy match using longest common substring
+ val minScore = (query.length * MATCH_THRESHOLD).toInt().coerceAtLeast(1)
+ val scored =
+ candidates
+ .map { it to longestCommonSubstringLength(query.lowercase(), it.name.lowercase()) }
+ .filter { (_, score) -> score >= minScore }
+ .sortedByDescending { it.second }
+
+ return when {
+ scored.isEmpty() -> NodeNameResult.NotFound
+
+ scored.size == 1 -> NodeNameResult.Found(scored[0].first.nodeNum, scored[0].first.userId)
+
+ scored[0].second > scored[1].second -> {
+ // Clear winner β€” top score is strictly greater
+ NodeNameResult.Found(scored[0].first.nodeNum, scored[0].first.userId)
+ }
+
+ else -> NodeNameResult.Ambiguous(scored.map { it.first.name })
+ }
+ }
+
+ private data class NameCandidate(val name: String, val nodeNum: Int, val userId: String)
+
+ private data class IndexedChannel(val name: String, val index: Int)
+
+ companion object {
+ /** Minimum match ratio β€” longest common substring must be β‰₯50% of query length. */
+ const val MATCH_THRESHOLD = 0.5
+ }
+}
+
+/** Compute the length of the longest common substring between two strings. */
+internal fun longestCommonSubstringLength(a: String, b: String): Int {
+ if (a.isEmpty() || b.isEmpty()) return 0
+ var maxLen = 0
+ // Space-optimized: only need previous row
+ val prev = IntArray(b.length + 1)
+ val curr = IntArray(b.length + 1)
+ for (i in 1..a.length) {
+ for (j in 1..b.length) {
+ curr[j] =
+ if (a[i - 1] == b[j - 1]) {
+ (prev[j - 1] + 1).also { if (it > maxLen) maxLen = it }
+ } else {
+ 0
+ }
+ }
+ prev.indices.forEach {
+ prev[it] = curr[it]
+ curr[it] = 0
+ }
+ }
+ return maxLen
+}
+
+sealed class NodeNameResult {
+ data class Found(val nodeNum: Int, val userId: String) : NodeNameResult()
+
+ data class Ambiguous(val candidates: List<String>) : NodeNameResult()
+
+ data object NotFound : NodeNameResult()
+}
+
+sealed class ChannelNameResult {
+ data class Found(val channelIndex: Int, val name: String) : ChannelNameResult()
+
+ data class Ambiguous(val candidates: List<String>) : ChannelNameResult()
+
+ data object NotFound : ChannelNameResult()
+}

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/RateLimiter.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/RateLimiter.kt
new file mode 100644
index 0000000000..58dc189177
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/RateLimiter.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.ai
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.koin.core.annotation.Single
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.Instant
+
+/**
+ * Sliding-window rate limiter for AI-triggered operations.
+ *
+ * Tracks the last [maxCalls] invocation timestamps. A new call is permitted only if fewer than [maxCalls] occurred
+ * within the [windowDuration]. This prevents aggregate AI traffic from flooding the mesh network.
+ *
+ * The limiter is intentionally process-scoped and global so concurrent AI surfaces share a single airtime budget.
+ */
+@Single
+class RateLimiter(private val clock: Clock) {
+
+ private val mutex = Mutex()
+ private val timestamps = ArrayDeque<Instant>(MAX_CALLS)
+
+ /**
+ * Attempt to acquire a permit for one invocation.
+ *
+ * @return [RateLimitResult.Permitted] if under the limit, or [RateLimitResult.Limited] with the number of seconds
+ * until a slot frees up.
+ */
+ suspend fun tryAcquire(): RateLimitResult = mutex.withLock {
+ val now = clock.now()
+ val windowStart = now - WINDOW_DURATION
+
+ // Evict timestamps outside the window
+ while (timestamps.isNotEmpty() && timestamps.first() <= windowStart) {
+ timestamps.removeFirst()
+ }
+
+ return if (timestamps.size < MAX_CALLS) {
+ timestamps.addLast(now)
+ RateLimitResult.Permitted
+ } else {
+ val oldestInWindow = timestamps.first()
+ val retryAfter = ((oldestInWindow + WINDOW_DURATION) - now).inWholeSeconds.toInt() + 1
+ RateLimitResult.Limited(retryAfterSeconds = retryAfter.coerceAtLeast(1))
+ }
+ }
+
+ companion object {
+ const val MAX_CALLS = 5
+ val WINDOW_DURATION = 60.seconds
+ }
+}
+
+sealed class RateLimitResult {
+ data object Permitted : RateLimitResult()
+
+ data class Limited(val retryAfterSeconds: Int) : RateLimitResult()
+}

diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt
new file mode 100644
index 0000000000..5b87e65133
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt
@@ -0,0 +1,272 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.ai
+
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.usecase.SendMessageUseCase
+import org.meshtastic.proto.User
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+class AiFunctionProviderImplTest {
+
+ private val connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Connected)
+ private val serviceRepository: ServiceRepository =
+ mock(MockMode.autofill) { every { connectionState } returns this@AiFunctionProviderImplTest.connectionState }
+ private val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ private val sendMessageUseCase: SendMessageUseCase = mock(MockMode.autofill)
+ private val fuzzyNameResolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ private val packetRepository: PacketRepository = mock(MockMode.autofill)
+ private val clock = TestClock(Instant.fromEpochSeconds(1_700_000_000))
+ private val rateLimiter = RateLimiter(clock)
+
+ private fun createProvider() = AiFunctionProviderImpl(
+ serviceRepository = serviceRepository,
+ nodeRepository = nodeRepository,
+ radioConfigRepository = radioConfigRepository,
+ sendMessageUseCase = sendMessageUseCase,
+ fuzzyNameResolver = fuzzyNameResolver,
+ packetRepository = packetRepository,
+ rateLimiter = rateLimiter,
+ clock = clock,
+ )
+
+ // --- getNodeDetails tests ---
+
+ @Test
+ fun getNodeDetails_returns_not_connected_when_disconnected() = runTest {
+ connectionState.value = ConnectionState.Disconnected
+ val provider = createProvider()
+
+ val result = provider.getNodeDetails("!abc123")
+ assertIs<GetNodeDetailsResult.NotConnected>(result)
+ }
+
+ @Test
+ fun getNodeDetails_returns_not_found_for_unknown_node() = runTest {
+ val nodeMap = MutableStateFlow(emptyMap<Int, Node>())
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+
+ val provider = createProvider()
+ val result = provider.getNodeDetails("!ffffff")
+
+ assertIs<GetNodeDetailsResult.NotFound>(result)
+ }
+
+ @Test
+ fun getNodeDetails_returns_node_data_for_valid_hex_id() = runTest {
+ val testNode =
+ Node(
+ num = 0xabc,
+ user = User(id = "!00000abc", long_name = "Alice", short_name = "AL"),
+ lastHeard = 1_700_000_000,
+ snr = 5.5f,
+ rssi = -70,
+ channel = 0,
+ hopsAway = 1,
+ )
+ val nodeMap = MutableStateFlow(mapOf(0xabc to testNode))
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+
+ val provider = createProvider()
+ val result = provider.getNodeDetails("!abc")
+
+ assertIs<GetNodeDetailsResult.Success>(result)
+ assertEquals("Alice", result.node.name)
+ assertEquals(5.5f, result.node.snr)
+ assertEquals(-70, result.node.rssi)
+ assertEquals(1, result.node.hopsAway)
+ }
+
+ @Test
+ fun getNodeDetails_returns_null_position_when_no_fix() = runTest {
+ // Node with (0.0, 0.0) position and time=0 β†’ no valid position
+ val testNode = Node(num = 1, user = User(id = "!00000001", long_name = "NoGPS", short_name = "NG"))
+ val nodeMap = MutableStateFlow(mapOf(1 to testNode))
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+
+ val provider = createProvider()
+ val result = provider.getNodeDetails("!1")
+
+ assertIs<GetNodeDetailsResult.Success>(result)
+ assertNull(result.node.latitude)
+ assertNull(result.node.longitude)
+ }
+
+ @Test
+ fun getNodeDetails_returns_error_for_invalid_hex_format() = runTest {
+ val nodeMap = MutableStateFlow(emptyMap<Int, Node>())
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+
+ val provider = createProvider()
+ val result = provider.getNodeDetails("!not_hex")
+
+ // Invalid hex should result in NotFound or Error
+ val isHandled = result is GetNodeDetailsResult.NotFound || result is GetNodeDetailsResult.Error
+ assertEquals(true, isHandled)
+ }
+
+ // --- getMeshMetrics tests ---
+
+ @Test
+ fun getMeshMetrics_returns_not_connected_when_disconnected() = runTest {
+ connectionState.value = ConnectionState.Disconnected
+ val provider = createProvider()
+
+ val result = provider.getMeshMetrics()
+ assertIs<GetMeshMetricsResult.NotConnected>(result)
+ }
+
+ @Test
+ fun getMeshMetrics_returns_valid_metrics_with_active_nodes() = runTest {
+ val nodes = mapOf(1 to Node(num = 1, lastHeard = 1_699_999_990), 2 to Node(num = 2, lastHeard = 1_699_999_980))
+ val nodeMap = MutableStateFlow(nodes)
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+ every { nodeRepository.totalNodeCount } returns flowOf(2)
+ every { nodeRepository.onlineNodeCount } returns flowOf(2)
+
+ val provider = createProvider()
+ val result = provider.getMeshMetrics()
+
+ assertIs<GetMeshMetricsResult.Success>(result)
+ assertEquals(2, result.metrics.totalNodeCount)
+ assertEquals(2, result.metrics.onlineNodeCount)
+ // Health score: 50 + (50 * 2) / 2 = 100
+ assertEquals(100, result.metrics.meshHealthScore)
+ // Most recent packet: 1_699_999_990 * 1000
+ assertEquals(1_699_999_990_000L, result.metrics.mostRecentPacketTime)
+ }
+
+ @Test
+ fun getMeshMetrics_returns_zero_health_score_when_empty() = runTest {
+ val nodeMap = MutableStateFlow(emptyMap<Int, Node>())
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+ every { nodeRepository.totalNodeCount } returns flowOf(0)
+ every { nodeRepository.onlineNodeCount } returns flowOf(0)
+
+ val provider = createProvider()
+ val result = provider.getMeshMetrics()
+
+ assertIs<GetMeshMetricsResult.Success>(result)
+ assertEquals(0, result.metrics.totalNodeCount)
+ assertEquals(0, result.metrics.meshHealthScore)
+ }
+
+ @Test
+ fun getMeshMetrics_falls_back_to_current_time_when_all_lastHeard_zero() = runTest {
+ val nodes = mapOf(1 to Node(num = 1, lastHeard = 0))
+ val nodeMap = MutableStateFlow(nodes)
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+ every { nodeRepository.totalNodeCount } returns flowOf(1)
+ every { nodeRepository.onlineNodeCount } returns flowOf(0)
+
+ val provider = createProvider()
+ val result = provider.getMeshMetrics()
+
+ assertIs<GetMeshMetricsResult.Success>(result)
+ // Falls back to clock.now() since all lastHeard are 0
+ assertEquals(clock.now().toEpochMilliseconds(), result.metrics.mostRecentPacketTime)
+ }
+
+ @Test
+ fun getMeshMetrics_returns_degraded_health_when_no_nodes_online() = runTest {
+ val nodes = mapOf(1 to Node(num = 1, lastHeard = 1_000))
+ val nodeMap = MutableStateFlow(nodes)
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+ every { nodeRepository.totalNodeCount } returns flowOf(1)
+ every { nodeRepository.onlineNodeCount } returns flowOf(0)
+
+ val provider = createProvider()
+ val result = provider.getMeshMetrics()
+
+ assertIs<GetMeshMetricsResult.Success>(result)
+ // HEALTH_SCORE_DEGRADED = 10
+ assertEquals(10, result.metrics.meshHealthScore)
+ }
+
+ // --- sendMessage error propagation test ---
+
+ @Test
+ fun sendMessage_returns_not_connected_when_disconnected() = runTest {
+ connectionState.value = ConnectionState.Disconnected
+ val provider = createProvider()
+
+ val result = provider.sendMessage("hello", null, null)
+ assertIs<SendMessageResult.NotConnected>(result)
+ }
+
+ @Test
+ fun sendMessage_returns_rate_limited_when_exhausted() = runTest {
+ val provider = createProvider()
+
+ // Exhaust rate limit
+ repeat(RateLimiter.MAX_CALLS) { rateLimiter.tryAcquire() }
+
+ val result = provider.sendMessage("hello", null, null)
+ assertIs<SendMessageResult.RateLimited>(result)
+ }
+
+ // --- getRecentMessages tests ---
+
+ @Test
+ fun getRecentMessages_contact_not_found() = runTest {
+ val nodeMap = MutableStateFlow(emptyMap<Int, Node>())
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+ every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet())
+
+ val provider = createProvider()
+ val result = provider.getRecentMessages("NonExistent", 10)
+ assertIs<GetRecentMessagesResult.ContactNotFound>(result)
+ }
+
+ // --- getUnreadSummary tests ---
+
+ @Test
+ fun getUnreadSummary_returns_empty_when_no_unread() = runTest {
+ every { packetRepository.getContacts() } returns flowOf(emptyMap())
+ every { packetRepository.getContactSettings() } returns flowOf(emptyMap())
+ every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet())
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
+
+ val provider = createProvider()
+ val result = provider.getUnreadSummary()
+ assertIs<GetUnreadSummaryResult.Success>(result)
+ assertEquals(0, result.summary.totalUnreadCount)
+ assertEquals(0, result.summary.contacts.size)
+ }
+}
+
+private class TestClock(var currentTime: Instant) : Clock {
+ override fun now(): Instant = currentTime
+}

diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt
new file mode 100644
index 0000000000..3e75bfe749
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.ai
+
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.User
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+
+class FuzzyNameResolverTest {
+
+ @Test
+ fun longestCommonSubstring_exact_match() {
+ assertEquals(5, longestCommonSubstringLength("hello", "hello"))
+ }
+
+ @Test
+ fun longestCommonSubstring_partial_match() {
+ assertEquals(3, longestCommonSubstringLength("abcdef", "xbcdx"))
+ }
+
+ @Test
+ fun longestCommonSubstring_no_match() {
+ assertEquals(0, longestCommonSubstringLength("abc", "xyz"))
+ }
+
+ @Test
+ fun longestCommonSubstring_empty_string() {
+ assertEquals(0, longestCommonSubstringLength("", "abc"))
+ assertEquals(0, longestCommonSubstringLength("abc", ""))
+ }
+
+ @Test
+ fun longestCommonSubstring_case_sensitive() {
+ // The function itself is case-sensitive; callers lowercase
+ assertEquals(0, longestCommonSubstringLength("ABC", "abc"))
+ }
+
+ @Test
+ fun longestCommonSubstring_longer_second() {
+ assertEquals(4, longestCommonSubstringLength("test", "this is a test string"))
+ }
+
+ // NodeNameResult / ChannelNameResult sealed classes are tested indirectly via
+ // the integration with AiFunctionProviderImpl, but we verify basic structure here.
+
+ @Test
+ fun nodeNameResult_found_carries_data() {
+ val result = NodeNameResult.Found(nodeNum = 42, userId = "!abcd1234")
+ assertIs<NodeNameResult.Found>(result)
+ assertEquals(42, result.nodeNum)
+ assertEquals("!abcd1234", result.userId)
+ }
+
+ @Test
+ fun nodeNameResult_ambiguous_carries_candidates() {
+ val result = NodeNameResult.Ambiguous(listOf("Alice", "Alicia"))
+ assertIs<NodeNameResult.Ambiguous>(result)
+ assertEquals(2, result.candidates.size)
+ }
+
+ @Test
+ fun channelNameResult_found_carries_data() {
+ val result = ChannelNameResult.Found(channelIndex = 1, name = "General")
+ assertIs<ChannelNameResult.Found>(result)
+ assertEquals(1, result.channelIndex)
+ assertEquals("General", result.name)
+ }
+
+ // --- Behavioral tests for resolveNodeName ---
+
+ @Test
+ fun resolveNodeName_exact_match_case_insensitive() {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val nodes =
+ mapOf(
+ 1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alice", short_name = "AL")),
+ 2 to Node(num = 2, user = User(id = "!00000002", long_name = "Bob", short_name = "BO")),
+ )
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveNodeName("alice")
+
+ assertIs<NodeNameResult.Found>(result)
+ assertEquals(1, result.nodeNum)
+ assertEquals("!00000001", result.userId)
+ }
+
+ @Test
+ fun resolveNodeName_fuzzy_match_single_candidate() {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val nodes =
+ mapOf(
+ 1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alexander", short_name = "AX")),
+ 2 to Node(num = 2, user = User(id = "!00000002", long_name = "Bob", short_name = "BO")),
+ )
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveNodeName("Alexan")
+
+ assertIs<NodeNameResult.Found>(result)
+ assertEquals(1, result.nodeNum)
+ }
+
+ @Test
+ fun resolveNodeName_ambiguous_returns_candidates() {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val nodes =
+ mapOf(
+ 1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alice Smith", short_name = "AS")),
+ 2 to Node(num = 2, user = User(id = "!00000002", long_name = "Alice Jones", short_name = "AJ")),
+ )
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveNodeName("Alice")
+
+ // "Alice" matches both equally via LCS
+ assertIs<NodeNameResult.Ambiguous>(result)
+ assertEquals(2, result.candidates.size)
+ }
+
+ @Test
+ fun resolveNodeName_not_found_when_no_nodes() {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveNodeName("Unknown")
+
+ assertIs<NodeNameResult.NotFound>(result)
+ }
+
+ @Test
+ fun resolveNodeName_not_found_when_no_match() {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val nodes = mapOf(1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alice", short_name = "AL")))
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveNodeName("Zzzzzz")
+
+ assertIs<NodeNameResult.NotFound>(result)
+ }
+
+ // --- Behavioral tests for resolveChannelName ---
+
+ @Test
+ fun resolveChannelName_exact_match() = runTest {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val channelSet =
+ ChannelSet(settings = listOf(ChannelSettings(name = "General"), ChannelSettings(name = "Emergency")))
+ every { radioConfigRepository.channelSetFlow } returns flowOf(channelSet)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveChannelName("General")
+
+ assertIs<ChannelNameResult.Found>(result)
+ assertEquals(0, result.channelIndex)
+ assertEquals("General", result.name)
+ }
+
+ @Test
+ fun resolveChannelName_excludes_admin_channel() = runTest {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val channelSet =
+ ChannelSet(settings = listOf(ChannelSettings(name = "admin"), ChannelSettings(name = "General")))
+ every { radioConfigRepository.channelSetFlow } returns flowOf(channelSet)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveChannelName("admin")
+
+ // "admin" should be excluded β€” cannot resolve to the admin channel
+ assertIs<ChannelNameResult.NotFound>(result)
+ }
+
+ @Test
+ fun resolveChannelName_not_found_when_empty() = runTest {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val channelSet = ChannelSet(settings = emptyList())
+ every { radioConfigRepository.channelSetFlow } returns flowOf(channelSet)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveChannelName("General")
+
+ assertIs<ChannelNameResult.NotFound>(result)
+ }
+}

diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/RateLimiterTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/RateLimiterTest.kt
new file mode 100644
index 0000000000..8d2f1ea3d9
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/RateLimiterTest.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.ai
+
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.Instant
+
+class RateLimiterTest {
+
+ @Test
+ fun permits_calls_under_limit() = runTest {
+ val clock = FakeClock(Instant.fromEpochSeconds(1000))
+ val rateLimiter = RateLimiter(clock)
+
+ repeat(RateLimiter.MAX_CALLS) { assertIs<RateLimitResult.Permitted>(rateLimiter.tryAcquire()) }
+ }
+
+ @Test
+ fun rejects_calls_over_limit() = runTest {
+ val clock = FakeClock(Instant.fromEpochSeconds(1000))
+ val rateLimiter = RateLimiter(clock)
+
+ // Exhaust the limit
+ repeat(RateLimiter.MAX_CALLS) { rateLimiter.tryAcquire() }
+
+ val result = rateLimiter.tryAcquire()
+ assertIs<RateLimitResult.Limited>(result)
+ assertEquals(61, result.retryAfterSeconds) // full window remaining + 1
+ }
+
+ @Test
+ fun permits_after_window_expires() = runTest {
+ val clock = FakeClock(Instant.fromEpochSeconds(1000))
+ val rateLimiter = RateLimiter(clock)
+
+ // Exhaust the limit
+ repeat(RateLimiter.MAX_CALLS) { rateLimiter.tryAcquire() }
+
+ // Advance past the window
+ clock.currentTime = Instant.fromEpochSeconds(1000) + RateLimiter.WINDOW_DURATION + 1.seconds
+
+ assertIs<RateLimitResult.Permitted>(rateLimiter.tryAcquire())
+ }
+
+ @Test
+ fun sliding_window_evicts_oldest_entry() = runTest {
+ val clock = FakeClock(Instant.fromEpochSeconds(1000))
+ val rateLimiter = RateLimiter(clock)
+
+ // Fill the window with calls 10 seconds apart
+ repeat(RateLimiter.MAX_CALLS) { i ->
+ clock.currentTime = Instant.fromEpochSeconds(1000L + i * 10)
+ rateLimiter.tryAcquire()
+ }
+
+ // At t=1050, first call (t=1000) is still in window (threshold is t=990)
+ clock.currentTime = Instant.fromEpochSeconds(1050)
+ assertIs<RateLimitResult.Limited>(rateLimiter.tryAcquire())
+
+ // At t=1061 β€” first call (t=1000) should have expired from window
+ clock.currentTime = Instant.fromEpochSeconds(1061)
+ assertIs<RateLimitResult.Permitted>(rateLimiter.tryAcquire())
+ }
+
+ @Test
+ fun retry_after_is_accurate() = runTest {
+ val clock = FakeClock(Instant.fromEpochSeconds(1000))
+ val rateLimiter = RateLimiter(clock)
+
+ // All calls at t=1000
+ repeat(RateLimiter.MAX_CALLS) { rateLimiter.tryAcquire() }
+
+ // Check at t=1030 (halfway through window)
+ clock.currentTime = Instant.fromEpochSeconds(1030)
+ val result = rateLimiter.tryAcquire()
+ assertIs<RateLimitResult.Limited>(result)
+ // Oldest at t=1000, expires at t=1060, now is t=1030, so retryAfter = 31
+ assertEquals(31, result.retryAfterSeconds)
+ }
+}
+
+/** Simple fake Clock for testing. */
+private class FakeClock(var currentTime: Instant) : Clock {
+ override fun now(): Instant = currentTime
+}

diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
index 964caec994..2f27056c31 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -168,6 +168,8 @@ sealed interface SettingsRoute : Route {
@Serializable data object NodeList : SettingsRoute
+ @Serializable data object AppFunctionsSettings : SettingsRoute
+
// endregion
// region help & documentation routes

diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/appfunctions/AppFunctionsPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/appfunctions/AppFunctionsPrefsImpl.kt
new file mode 100644
index 0000000000..fd66741f7a
--- /dev/null
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/appfunctions/AppFunctionsPrefsImpl.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.prefs.appfunctions
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.koin.core.annotation.Named
+import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.AppFunctionsPrefs
+
+@Single
+@Suppress("TooManyFunctions")
+class AppFunctionsPrefsImpl(
+ @Named("AppDataStore") private val dataStore: DataStore<Preferences>,
+ dispatchers: CoroutineDispatchers,
+) : AppFunctionsPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val masterEnabled: StateFlow<Boolean> = booleanPref(KEY_MASTER, true)
+ override val sendMessageEnabled: StateFlow<Boolean> = booleanPref(KEY_SEND_MESSAGE, true)
+ override val getMeshStatusEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_MESH_STATUS, true)
+ override val getNodeListEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_NODE_LIST, true)
+ override val getChannelInfoEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_CHANNEL_INFO, true)
+ override val getDeviceStatusEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_DEVICE_STATUS, true)
+ override val getNodeDetailsEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_NODE_DETAILS, true)
+ override val getMeshMetricsEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_MESH_METRICS, true)
+ override val getRecentMessagesEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_RECENT_MESSAGES, true)
+ override val getUnreadSummaryEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_UNREAD_SUMMARY, true)
+
+ override fun setMasterEnabled(enabled: Boolean) = set(KEY_MASTER, enabled)
+
+ override fun setSendMessageEnabled(enabled: Boolean) = set(KEY_SEND_MESSAGE, enabled)
+
+ override fun setGetMeshStatusEnabled(enabled: Boolean) = set(KEY_GET_MESH_STATUS, enabled)
+
+ override fun setGetNodeListEnabled(enabled: Boolean) = set(KEY_GET_NODE_LIST, enabled)
+
+ override fun setGetChannelInfoEnabled(enabled: Boolean) = set(KEY_GET_CHANNEL_INFO, enabled)
+
+ override fun setGetDeviceStatusEnabled(enabled: Boolean) = set(KEY_GET_DEVICE_STATUS, enabled)
+
+ override fun setGetNodeDetailsEnabled(enabled: Boolean) = set(KEY_GET_NODE_DETAILS, enabled)
+
+ override fun setGetMeshMetricsEnabled(enabled: Boolean) = set(KEY_GET_MESH_METRICS, enabled)
+
+ override fun setGetRecentMessagesEnabled(enabled: Boolean) = set(KEY_GET_RECENT_MESSAGES, enabled)
+
+ override fun setGetUnreadSummaryEnabled(enabled: Boolean) = set(KEY_GET_UNREAD_SUMMARY, enabled)
+
+ private fun booleanPref(key: Preferences.Key<Boolean>, default: Boolean): StateFlow<Boolean> =
+ dataStore.data.map { it[key] ?: default }.stateIn(scope, SharingStarted.Eagerly, default)
+
+ private fun set(key: Preferences.Key<Boolean>, value: Boolean) {
+ scope.launch { dataStore.edit { prefs -> prefs[key] = value } }
+ }
+
+ companion object {
+ private val KEY_MASTER = booleanPreferencesKey("appfn_master_enabled")
+ private val KEY_SEND_MESSAGE = booleanPreferencesKey("appfn_send_message")
+ private val KEY_GET_MESH_STATUS = booleanPreferencesKey("appfn_get_mesh_status")
+ private val KEY_GET_NODE_LIST = booleanPreferencesKey("appfn_get_node_list")
+ private val KEY_GET_CHANNEL_INFO = booleanPreferencesKey("appfn_get_channel_info")
+ private val KEY_GET_DEVICE_STATUS = booleanPreferencesKey("appfn_get_device_status")
+ private val KEY_GET_NODE_DETAILS = booleanPreferencesKey("appfn_get_node_details")
+ private val KEY_GET_MESH_METRICS = booleanPreferencesKey("appfn_get_mesh_metrics")
+ private val KEY_GET_RECENT_MESSAGES = booleanPreferencesKey("appfn_get_recent_messages")
+ private val KEY_GET_UNREAD_SUMMARY = booleanPreferencesKey("appfn_get_unread_summary")
+ }
+}

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index 1e0cbd29d7..c7f891a6f4 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -296,9 +296,53 @@ interface TakPrefs {
fun setTakServerEnabled(enabled: Boolean)
}
+/** Reactive interface for App Functions (system AI integration) preferences. */
+interface AppFunctionsPrefs {
+ val masterEnabled: StateFlow<Boolean>
+
+ fun setMasterEnabled(enabled: Boolean)
+
+ val sendMessageEnabled: StateFlow<Boolean>
+
+ fun setSendMessageEnabled(enabled: Boolean)
+
+ val getMeshStatusEnabled: StateFlow<Boolean>
+
+ fun setGetMeshStatusEnabled(enabled: Boolean)
+
+ val getNodeListEnabled: StateFlow<Boolean>
+
+ fun setGetNodeListEnabled(enabled: Boolean)
+
+ val getChannelInfoEnabled: StateFlow<Boolean>
+
+ fun setGetChannelInfoEnabled(enabled: Boolean)
+
+ val getDeviceStatusEnabled: StateFlow<Boolean>
+
+ fun setGetDeviceStatusEnabled(enabled: Boolean)
+
+ val getNodeDetailsEnabled: StateFlow<Boolean>
+
+ fun setGetNodeDetailsEnabled(enabled: Boolean)
+
+ val getMeshMetricsEnabled: StateFlow<Boolean>
+
+ fun setGetMeshMetricsEnabled(enabled: Boolean)
+
+ val getRecentMessagesEnabled: StateFlow<Boolean>
+
+ fun setGetRecentMessagesEnabled(enabled: Boolean)
+
+ val getUnreadSummaryEnabled: StateFlow<Boolean>
+
+ fun setGetUnreadSummaryEnabled(enabled: Boolean)
+}
+
/** Consolidated interface for all application preferences. */
interface AppPreferences {
val analytics: AnalyticsPrefs
+ val appFunctions: AppFunctionsPrefs
val homoglyph: HomoglyphPrefs
val filter: FilterPrefs
val meshLog: MeshLogPrefs

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
index 7fc17c2a9e..987c343ee7 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
@@ -44,7 +44,11 @@ import kotlin.random.Random
* This implementation is platform-agnostic and relies on injected repositories and controllers.
*/
interface SendMessageUseCase {
- suspend operator fun invoke(text: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null)
+ suspend operator fun invoke(
+ text: String,
+ contactKey: String = "0${DataPacket.ID_BROADCAST}",
+ replyId: Int? = null,
+ ): Int
}
@Suppress("TooGenericExceptionCaught")
@@ -64,7 +68,7 @@ class SendMessageUseCaseImpl(
* @param replyId Optional ID of a message being replied to.
*/
@Suppress("NestedBlockDepth", "LongMethod", "CyclomaticComplexMethod")
- override suspend operator fun invoke(text: String, contactKey: String, replyId: Int?) {
+ override suspend operator fun invoke(text: String, contactKey: String, replyId: Int?): Int {
val channel = contactKey[0].digitToIntOrNull()
val dest = if (channel != null) contactKey.substring(1) else contactKey
@@ -125,7 +129,10 @@ class SendMessageUseCaseImpl(
messageQueue.enqueue(packetId)
} catch (ex: Exception) {
Logger.e(ex) { "Failed to enqueue message packet" }
+ throw ex
}
+
+ return packetId
}
private suspend fun favoriteNode(node: Node) {

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 6c59d355d7..1d32c061b5 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -86,6 +86,22 @@
<string name="analytics_okay">Allow analytics and crash reporting.</string>
<string name="analytics_platforms">Analytics platforms:</string>
<string name="any">Any</string>
+ <!-- APP -->
+ <string name="app_functions_get_channel_info">Get channel info</string>
+ <string name="app_functions_get_device_status">Get device status</string>
+ <string name="app_functions_get_mesh_metrics">Get mesh metrics</string>
+ <string name="app_functions_get_mesh_status">Get mesh status</string>
+ <string name="app_functions_get_node_details">Get node details</string>
+ <string name="app_functions_get_node_list">Get node list</string>
+ <string name="app_functions_get_recent_messages">Get recent messages</string>
+ <string name="app_functions_get_unread_summary">Get unread summary</string>
+ <string name="app_functions_master_summary">Let system AI assistants (e.g. Gemini) discover and use mesh functions</string>
+ <string name="app_functions_master_toggle">Allow AI access</string>
+ <string name="app_functions_read_section">Read functions</string>
+ <string name="app_functions_send_message">Send message</string>
+ <string name="app_functions_settings">System AI</string>
+ <string name="app_functions_settings_summary">Control which functions are available to AI assistants</string>
+ <string name="app_functions_write_section">Write functions</string>
<string name="app_notifications">App Notifications</string>
<string name="app_settings">App</string>
<string name="app_too_old">Application update required</string>

diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index bef3d2be13..c83a5815a9 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -19,6 +19,7 @@ package org.meshtastic.core.testing
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.repository.AnalyticsPrefs
+import org.meshtastic.core.repository.AppFunctionsPrefs
import org.meshtastic.core.repository.AppPreferences
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.FilterPrefs
@@ -336,8 +337,71 @@ class FakeMeshPrefs : MeshPrefs {
}
}
+class FakeAppFunctionsPrefs : AppFunctionsPrefs {
+ override val masterEnabled = MutableStateFlow(true)
+
+ override fun setMasterEnabled(enabled: Boolean) {
+ masterEnabled.value = enabled
+ }
+
+ override val sendMessageEnabled = MutableStateFlow(true)
+
+ override fun setSendMessageEnabled(enabled: Boolean) {
+ sendMessageEnabled.value = enabled
+ }
+
+ override val getMeshStatusEnabled = MutableStateFlow(true)
+
+ override fun setGetMeshStatusEnabled(enabled: Boolean) {
+ getMeshStatusEnabled.value = enabled
+ }
+
+ override val getNodeListEnabled = MutableStateFlow(true)
+
+ override fun setGetNodeListEnabled(enabled: Boolean) {
+ getNodeListEnabled.value = enabled
+ }
+
+ override val getChannelInfoEnabled = MutableStateFlow(true)
+
+ override fun setGetChannelInfoEnabled(enabled: Boolean) {
+ getChannelInfoEnabled.value = enabled
+ }
+
+ override val getDeviceStatusEnabled = MutableStateFlow(true)
+
+ override fun setGetDeviceStatusEnabled(enabled: Boolean) {
+ getDeviceStatusEnabled.value = enabled
+ }
+
+ override val getNodeDetailsEnabled = MutableStateFlow(true)
+
+ override fun setGetNodeDetailsEnabled(enabled: Boolean) {
+ getNodeDetailsEnabled.value = enabled
+ }
+
+ override val getMeshMetricsEnabled = MutableStateFlow(true)
+
+ override fun setGetMeshMetricsEnabled(enabled: Boolean) {
+ getMeshMetricsEnabled.value = enabled
+ }
+
+ override val getRecentMessagesEnabled = MutableStateFlow(true)
+
+ override fun setGetRecentMessagesEnabled(enabled: Boolean) {
+ getRecentMessagesEnabled.value = enabled
+ }
+
+ override val getUnreadSummaryEnabled = MutableStateFlow(true)
+
+ override fun setGetUnreadSummaryEnabled(enabled: Boolean) {
+ getUnreadSummaryEnabled.value = enabled
+ }
+}
+
class FakeAppPreferences : AppPreferences {
override val analytics = FakeAnalyticsPrefs()
+ override val appFunctions = FakeAppFunctionsPrefs()
override val homoglyph = FakeHomoglyphPrefs()
override val filter = FakeFilterPrefs()
override val meshLog = FakeMeshLogPrefs()

diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
index 80877834b6..84944d7bab 100644
--- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
@@ -179,7 +179,7 @@ class MessageViewModelTest {
@Test
fun testSendMessage() = runTest {
- everySuspend { sendMessageUseCase.invoke(any(), any(), any()) } returns Unit
+ everySuspend { sendMessageUseCase.invoke(any(), any(), any()) } returns 1
viewModel.sendMessage("Hello", "0!12345678", null)

diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index d894e76444..d6d74cd654 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -37,6 +37,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.eygraber.uri.toKmpUri
import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.koinInject
+import org.koin.core.qualifier.named
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
@@ -44,6 +46,8 @@ import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.navigation.WifiProvisionRoute
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.app_functions_settings
+import org.meshtastic.core.resources.app_functions_settings_summary
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.export_configuration
import org.meshtastic.core.resources.filter_settings
@@ -60,6 +64,7 @@ import org.meshtastic.core.ui.icon.FilterList
import org.meshtastic.core.ui.icon.HelpOutline
import org.meshtastic.core.ui.icon.List
import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.SettingsRemote
import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.feature.settings.component.AppInfoSection
import org.meshtastic.feature.settings.component.AppearanceSection
@@ -87,6 +92,7 @@ fun SettingsScreen(
onNavigate: (Route) -> Unit = {},
onBack: (() -> Unit)? = null,
) {
+ val appFunctionsAvailable: Boolean = koinInject(qualifier = named("googleServicesAvailable"))
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
@@ -226,7 +232,7 @@ fun SettingsScreen(
// App-local settings are only relevant when configuring the local node
if (state.isLocal) {
PrivacySection(
- analyticsAvailable = state.analyticsAvailable,
+ analyticsAvailable = appFunctionsAvailable,
analyticsEnabled = viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(true).value,
onToggleAnalytics = { viewModel.toggleAnalyticsAllowed() },
provideLocation = settingsViewModel.provideLocation.collectAsStateWithLifecycle().value,
@@ -266,6 +272,18 @@ fun SettingsScreen(
}
}
+ if (appFunctionsAvailable) {
+ ExpressiveSection(title = stringResource(Res.string.app_functions_settings)) {
+ ListItem(
+ text = stringResource(Res.string.app_functions_settings),
+ supportingText = stringResource(Res.string.app_functions_settings_summary),
+ leadingIcon = MeshtasticIcons.SettingsRemote,
+ ) {
+ onNavigate(SettingsRoute.AppFunctionsSettings)
+ }
+ }
+ }
+
PersistenceSection(
cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/appfunctions/AppFunctionsSettingsScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/appfunctions/AppFunctionsSettingsScreen.kt
new file mode 100644
index 0000000000..368150c088
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/appfunctions/AppFunctionsSettingsScreen.kt
@@ -0,0 +1,226 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings.appfunctions
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.app_functions_get_channel_info
+import org.meshtastic.core.resources.app_functions_get_device_status
+import org.meshtastic.core.resources.app_functions_get_mesh_metrics
+import org.meshtastic.core.resources.app_functions_get_mesh_status
+import org.meshtastic.core.resources.app_functions_get_node_details
+import org.meshtastic.core.resources.app_functions_get_node_list
+import org.meshtastic.core.resources.app_functions_get_recent_messages
+import org.meshtastic.core.resources.app_functions_get_unread_summary
+import org.meshtastic.core.resources.app_functions_master_summary
+import org.meshtastic.core.resources.app_functions_master_toggle
+import org.meshtastic.core.resources.app_functions_read_section
+import org.meshtastic.core.resources.app_functions_send_message
+import org.meshtastic.core.resources.app_functions_settings
+import org.meshtastic.core.resources.app_functions_write_section
+import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.core.ui.component.SwitchListItem
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.SettingsRemote
+
+@Composable
+fun AppFunctionsSettingsScreen(
+ viewModel: AppFunctionsSettingsViewModel,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val masterEnabled by viewModel.masterEnabled.collectAsStateWithLifecycle()
+ val sendMessage by viewModel.sendMessageEnabled.collectAsStateWithLifecycle()
+ val getMeshStatus by viewModel.getMeshStatusEnabled.collectAsStateWithLifecycle()
+ val getNodeList by viewModel.getNodeListEnabled.collectAsStateWithLifecycle()
+ val getChannelInfo by viewModel.getChannelInfoEnabled.collectAsStateWithLifecycle()
+ val getDeviceStatus by viewModel.getDeviceStatusEnabled.collectAsStateWithLifecycle()
+ val getNodeDetails by viewModel.getNodeDetailsEnabled.collectAsStateWithLifecycle()
+ val getMeshMetrics by viewModel.getMeshMetricsEnabled.collectAsStateWithLifecycle()
+ val getRecentMessages by viewModel.getRecentMessagesEnabled.collectAsStateWithLifecycle()
+ val getUnreadSummary by viewModel.getUnreadSummaryEnabled.collectAsStateWithLifecycle()
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ MainAppBar(
+ title = stringResource(Res.string.app_functions_settings),
+ canNavigateUp = true,
+ onNavigateUp = onBack,
+ ourNode = null,
+ showNodeChip = false,
+ actions = {},
+ onClickChip = {},
+ )
+ },
+ ) { padding ->
+ Column(modifier = Modifier.padding(padding).verticalScroll(rememberScrollState())) {
+ MasterToggleSection(
+ masterEnabled = masterEnabled,
+ onToggle = { viewModel.setMasterEnabled(!masterEnabled) },
+ )
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ WriteFunctionsSection(
+ masterEnabled = masterEnabled,
+ sendMessage = sendMessage,
+ onToggleSendMessage = { viewModel.setSendMessageEnabled(!sendMessage) },
+ )
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ ReadFunctionsSection(
+ masterEnabled = masterEnabled,
+ getMeshStatus = getMeshStatus,
+ onToggleMeshStatus = { viewModel.setGetMeshStatusEnabled(!getMeshStatus) },
+ getNodeList = getNodeList,
+ onToggleNodeList = { viewModel.setGetNodeListEnabled(!getNodeList) },
+ getChannelInfo = getChannelInfo,
+ onToggleChannelInfo = { viewModel.setGetChannelInfoEnabled(!getChannelInfo) },
+ getDeviceStatus = getDeviceStatus,
+ onToggleDeviceStatus = { viewModel.setGetDeviceStatusEnabled(!getDeviceStatus) },
+ getNodeDetails = getNodeDetails,
+ onToggleNodeDetails = { viewModel.setGetNodeDetailsEnabled(!getNodeDetails) },
+ getMeshMetrics = getMeshMetrics,
+ onToggleMeshMetrics = { viewModel.setGetMeshMetricsEnabled(!getMeshMetrics) },
+ getRecentMessages = getRecentMessages,
+ onToggleRecentMessages = { viewModel.setGetRecentMessagesEnabled(!getRecentMessages) },
+ getUnreadSummary = getUnreadSummary,
+ onToggleUnreadSummary = { viewModel.setGetUnreadSummaryEnabled(!getUnreadSummary) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun MasterToggleSection(masterEnabled: Boolean, onToggle: () -> Unit) {
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_master_toggle),
+ checked = masterEnabled,
+ leadingIcon = MeshtasticIcons.SettingsRemote,
+ onClick = onToggle,
+ )
+ Text(
+ text = stringResource(Res.string.app_functions_master_summary),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 56.dp, end = 16.dp, bottom = 8.dp),
+ )
+}
+
+@Composable
+private fun WriteFunctionsSection(masterEnabled: Boolean, sendMessage: Boolean, onToggleSendMessage: () -> Unit) {
+ Text(
+ text = stringResource(Res.string.app_functions_write_section),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_send_message),
+ checked = sendMessage,
+ enabled = masterEnabled,
+ onClick = onToggleSendMessage,
+ )
+}
+
+@Suppress("LongParameterList")
+@Composable
+private fun ReadFunctionsSection(
+ masterEnabled: Boolean,
+ getMeshStatus: Boolean,
+ onToggleMeshStatus: () -> Unit,
+ getNodeList: Boolean,
+ onToggleNodeList: () -> Unit,
+ getChannelInfo: Boolean,
+ onToggleChannelInfo: () -> Unit,
+ getDeviceStatus: Boolean,
+ onToggleDeviceStatus: () -> Unit,
+ getNodeDetails: Boolean,
+ onToggleNodeDetails: () -> Unit,
+ getMeshMetrics: Boolean,
+ onToggleMeshMetrics: () -> Unit,
+ getRecentMessages: Boolean,
+ onToggleRecentMessages: () -> Unit,
+ getUnreadSummary: Boolean,
+ onToggleUnreadSummary: () -> Unit,
+) {
+ Text(
+ text = stringResource(Res.string.app_functions_read_section),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_mesh_status),
+ checked = getMeshStatus,
+ enabled = masterEnabled,
+ onClick = onToggleMeshStatus,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_node_list),
+ checked = getNodeList,
+ enabled = masterEnabled,
+ onClick = onToggleNodeList,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_channel_info),
+ checked = getChannelInfo,
+ enabled = masterEnabled,
+ onClick = onToggleChannelInfo,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_device_status),
+ checked = getDeviceStatus,
+ enabled = masterEnabled,
+ onClick = onToggleDeviceStatus,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_node_details),
+ checked = getNodeDetails,
+ enabled = masterEnabled,
+ onClick = onToggleNodeDetails,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_mesh_metrics),
+ checked = getMeshMetrics,
+ enabled = masterEnabled,
+ onClick = onToggleMeshMetrics,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_recent_messages),
+ checked = getRecentMessages,
+ enabled = masterEnabled,
+ onClick = onToggleRecentMessages,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_unread_summary),
+ checked = getUnreadSummary,
+ enabled = masterEnabled,
+ onClick = onToggleUnreadSummary,
+ )
+}

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/appfunctions/AppFunctionsSettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/appfunctions/AppFunctionsSettingsViewModel.kt
new file mode 100644
index 0000000000..4069f63d9e
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/appfunctions/AppFunctionsSettingsViewModel.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings.appfunctions
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.flow.StateFlow
+import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.repository.AppFunctionsPrefs
+
+@KoinViewModel
+class AppFunctionsSettingsViewModel(private val prefs: AppFunctionsPrefs) : ViewModel() {
+
+ val masterEnabled: StateFlow<Boolean> = prefs.masterEnabled
+ val sendMessageEnabled: StateFlow<Boolean> = prefs.sendMessageEnabled
+ val getMeshStatusEnabled: StateFlow<Boolean> = prefs.getMeshStatusEnabled
+ val getNodeListEnabled: StateFlow<Boolean> = prefs.getNodeListEnabled
+ val getChannelInfoEnabled: StateFlow<Boolean> = prefs.getChannelInfoEnabled
+ val getDeviceStatusEnabled: StateFlow<Boolean> = prefs.getDeviceStatusEnabled
+ val getNodeDetailsEnabled: StateFlow<Boolean> = prefs.getNodeDetailsEnabled
+ val getMeshMetricsEnabled: StateFlow<Boolean> = prefs.getMeshMetricsEnabled
+ val getRecentMessagesEnabled: StateFlow<Boolean> = prefs.getRecentMessagesEnabled
+ val getUnreadSummaryEnabled: StateFlow<Boolean> = prefs.getUnreadSummaryEnabled
+
+ fun setMasterEnabled(enabled: Boolean) = prefs.setMasterEnabled(enabled)
+
+ fun setSendMessageEnabled(enabled: Boolean) = prefs.setSendMessageEnabled(enabled)
+
+ fun setGetMeshStatusEnabled(enabled: Boolean) = prefs.setGetMeshStatusEnabled(enabled)
+
+ fun setGetNodeListEnabled(enabled: Boolean) = prefs.setGetNodeListEnabled(enabled)
+
+ fun setGetChannelInfoEnabled(enabled: Boolean) = prefs.setGetChannelInfoEnabled(enabled)
+
+ fun setGetDeviceStatusEnabled(enabled: Boolean) = prefs.setGetDeviceStatusEnabled(enabled)
+
+ fun setGetNodeDetailsEnabled(enabled: Boolean) = prefs.setGetNodeDetailsEnabled(enabled)
+
+ fun setGetMeshMetricsEnabled(enabled: Boolean) = prefs.setGetMeshMetricsEnabled(enabled)
+
+ fun setGetRecentMessagesEnabled(enabled: Boolean) = prefs.setGetRecentMessagesEnabled(enabled)
+
+ fun setGetUnreadSummaryEnabled(enabled: Boolean) = prefs.setGetUnreadSummaryEnabled(enabled)
+}

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
index d9ed2c18ef..a38c3665f6 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
@@ -36,6 +36,8 @@ import org.meshtastic.feature.settings.DeviceConfigurationScreen
import org.meshtastic.feature.settings.ModuleConfigurationScreen
import org.meshtastic.feature.settings.NodeListScreen
import org.meshtastic.feature.settings.SettingsViewModel
+import org.meshtastic.feature.settings.appfunctions.AppFunctionsSettingsScreen
+import org.meshtastic.feature.settings.appfunctions.AppFunctionsSettingsViewModel
import org.meshtastic.feature.settings.debugging.DebugScreen
import org.meshtastic.feature.settings.debugging.DebugViewModel
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
@@ -248,6 +250,11 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
)
}
+
+ entry<SettingsRoute.AppFunctionsSettings> {
+ val viewModel: AppFunctionsSettingsViewModel = koinViewModel()
+ AppFunctionsSettingsScreen(viewModel = viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() })
+ }
}
/** Expect declaration for the platform-specific settings main screen. */

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a0f66bdc57..ae33f0276e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -5,6 +5,7 @@ xmlutil = "0.91.3"
agp = "9.2.1"
appcompat = "1.7.1"
accompanist = "0.37.3"
+appfunctions = "1.0.0-alpha09"
# androidx
datastore = "1.2.1"
@@ -103,6 +104,9 @@ xmlutil-serialization = { module = "io.github.pdvrieze.xmlutil:serialization", v
androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.13.0" }
androidx-annotation = { module = "androidx.annotation:annotation", version = "1.10.0" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
+androidx-appfunctions = { module = "androidx.appfunctions:appfunctions", version.ref = "appfunctions" }
+androidx-appfunctions-compiler = { module = "androidx.appfunctions:appfunctions-compiler", version.ref = "appfunctions" }
+androidx-appfunctions-service = { module = "androidx.appfunctions:appfunctions-service", version.ref = "appfunctions" }
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" }
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" }
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" }

diff --git a/specs/20260521-091500-app-functions/checklist.md b/specs/20260521-091500-app-functions/checklist.md
new file mode 100644
index 0000000000..10a0875c32
--- /dev/null
+++ b/specs/20260521-091500-app-functions/checklist.md
@@ -0,0 +1,97 @@
+# Implementation Checklist: Android App Functions Integration
+
+> Auto-generated from `specs/20260521-091500-app-functions/spec.md`
+
+## Pre-Implementation
+
+- [ ] **Read skill docs**: `.skills/kmp-architecture/SKILL.md` for source-set rules
+- [ ] **Bootstrap**: Run `git submodule update --init && [ -f local.properties ] || cp secrets.defaults.properties local.properties`
+- [ ] **Baseline verification**: `./gradlew spotlessApply detekt assembleDebug test allTests` passes before any changes
+- [ ] **Confirm compileSdk**: Check current `compileSdk` in `build-logic/` β€” must be β‰₯ 36 for AppFunctions
+- [ ] **Confirm KSP setup**: Verify KSP plugin is already applied in `androidApp/build.gradle.kts`
+
+## Dependencies & Build Configuration
+
+- [ ] Add `androidx.appfunctions:appfunctions:1.0.0-alpha09` to androidApp dependencies
+- [ ] Add `androidx.appfunctions:appfunctions-service:1.0.0-alpha09` to androidApp dependencies
+- [ ] Add `androidx.appfunctions:appfunctions-compiler:1.0.0-alpha09` as KSP processor
+- [ ] Add `ksp { arg("appfunctions:aggregateAppFunctions", "true") }` to androidApp build config
+- [ ] Bump `compileSdk` to 36 if not already (check build-logic conventions plugin)
+- [ ] Verify build compiles: `./gradlew :androidApp:compileGoogleDebugKotlin`
+
+## commonMain: Platform-Agnostic Contracts (`core/data`)
+
+- [ ] Create `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/` package
+- [ ] **AiFunctionProvider.kt**: Interface with `sendMessage()` and `getMeshStatus()` suspend functions
+- [ ] **AiFunctionResult.kt**: Sealed class hierarchy for success/error results (no Android dependencies!)
+- [ ] **FuzzyNameResolver.kt**: Longest-substring matching logic; returns single match or throws with candidates
+- [ ] **RateLimiter.kt**: Token-bucket implementation (5 tokens, 60s refill); use `kotlinx.datetime` or `Clock` for time
+- [ ] Unit tests for `FuzzyNameResolver` β€” exact match, single fuzzy match, ambiguous match, no match
+- [ ] Unit tests for `RateLimiter` β€” under limit, at limit, over limit, refill after window
+- [ ] Verify no `android.*` or `java.*` imports in any commonMain files
+- [ ] Run: `./gradlew :core:data:allTests`
+
+## commonMain: AiFunctionProvider Implementation
+
+- [ ] Create `AiFunctionProviderImpl.kt` wiring to existing repositories
+- [ ] Inject `NodeRepository`, `ServiceRepository`, `CommandSender`, `RadioConfigRepository` via constructor
+- [ ] `sendMessage`: Check connection β†’ rate limit β†’ resolve name β†’ validate length β†’ send β†’ return result
+- [ ] `getMeshStatus`: Read connection state, node counts, battery from existing flows (`.first()`)
+- [ ] Register in Koin module (`core/data` DI module)
+- [ ] Integration test: `AiFunctionProviderImpl` with mocked repositories
+
+## androidApp: App Function Declarations (Google flavor)
+
+- [ ] Create `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/` package
+- [ ] **MeshtasticAppFunctions.kt**: Class with `@AppFunction(isDescribedByKDoc = true)` methods
+ - [ ] `sendMessage(appFunctionContext, text, recipientName?, channelName?)` β†’ `SendMessageResponse`
+ - [ ] `getMeshStatus(appFunctionContext)` β†’ `MeshStatusResponse`
+- [ ] **models/SendMessageResponse.kt**: `@AppFunctionSerializable` with messageId, timestamp, channel
+- [ ] **models/MeshStatusResponse.kt**: `@AppFunctionSerializable` with connectionState, onlineNodes, totalNodes, batteryLevel
+- [ ] **AppFunctionFactory.kt**: `AppFunctionConfiguration.Provider` using Koin to resolve `AiFunctionProviderImpl`
+- [ ] Register `AppFunctionConfiguration.Provider` in `GoogleMeshUtilApplication` (Google flavor subclass)
+- [ ] KDoc on every `@AppFunction` method β€” clear enough for AI agent to understand without context
+- [ ] KDoc on every `@AppFunctionSerializable` field β€” descriptive for schema generation
+
+## Error Handling
+
+- [ ] Disconnected state β†’ throw `AppFunctionAppException("Not connected to a Meshtastic radio")`
+- [ ] Ambiguous name match β†’ throw `AppFunctionInvalidArgumentException` with candidate list in message
+- [ ] No name match β†’ throw `AppFunctionElementNotFoundException`
+- [ ] Message too long β†’ throw `AppFunctionInvalidArgumentException` with max length info
+- [ ] Rate limit exceeded β†’ throw `AppFunctionLimitExceededException`
+- [ ] Timeout (>5s) β†’ throw `AppFunctionCancelledException`
+- [ ] No generic `Exception` or `RuntimeException` thrown from AppFunction methods
+
+## Security & Privacy
+
+- [ ] No admin channel data exposed in any response
+- [ ] No encryption keys or PSK material in responses
+- [ ] No raw protobuf payloads returned β€” only structured, safe data
+- [ ] No PII beyond what user has already shared on mesh (node names, messages are user-consented)
+- [ ] Rate limiter prevents AI-driven mesh flooding
+
+## Testing & Verification
+
+- [ ] `./gradlew :core:data:allTests` β€” commonMain unit tests pass
+- [ ] `./gradlew :androidApp:testGoogleDebugUnitTest` β€” Android unit tests pass
+- [ ] `./gradlew :androidApp:assembleGoogleDebug` β€” builds successfully
+- [ ] `./gradlew spotlessApply spotlessCheck` β€” formatting passes
+- [ ] `./gradlew detekt` β€” static analysis passes
+- [ ] `adb shell cmd app_function list-app-functions | grep org.meshtastic` β€” functions registered on device
+- [ ] Manual test: invoke via test agent app on API 35+ device/emulator
+- [ ] Verify rate limiting works (5 rapid calls β†’ exception on 6th)
+- [ ] Verify disconnected state returns proper error (not crash)
+
+## Documentation
+
+- [ ] KDoc comprehensive on all public APIs
+- [ ] Update spec status from "Draft" to "Implemented" after verification
+- [ ] Add entry to CHANGELOG.md under next release
+
+## Final Verification
+
+- [ ] Full verification pass: `./gradlew spotlessApply detekt assembleDebug test allTests`
+- [ ] No regressions in existing tests
+- [ ] PR description references spec: `specs/20260521-091500-app-functions/spec.md`
+- [ ] Branch naming follows convention

diff --git a/specs/20260521-091500-app-functions/plan.md b/specs/20260521-091500-app-functions/plan.md
new file mode 100644
index 0000000000..cd80baf740
--- /dev/null
+++ b/specs/20260521-091500-app-functions/plan.md
@@ -0,0 +1,243 @@
+# Implementation Plan: Android App Functions Integration
+
+**Spec**: `specs/20260521-091500-app-functions/spec.md`
+**Branch**: `jamesarich/crispy-barnacle`
+**Created**: 2026-05-21
+
+## Overview
+
+Implement a minimal MVP (2 App Functions: `sendMessage` + `getMeshStatus`) to validate the Meshtastic ↔ Android system AI integration pattern. The architecture follows KMP conventions: platform-agnostic interfaces + logic in `commonMain`, Android-specific `@AppFunction` wiring in the Google flavor.
+
+## Key Findings from Exploration
+
+- **compileSdk = 37** (already satisfies the β‰₯36 requirement)
+- **Koin uses its own compiler plugin** (not KSP) β€” AppFunctions KSP processor is separate and needs the `com.google.devtools.ksp` Gradle plugin applied to `androidApp`
+- **Google flavor already has `ai/` package** with `GeminiNanoDocAssistant.kt` and `GoogleAiModule.kt` in DI
+- **`FlavorModule.kt`** includes `GoogleAiModule` β€” we'll add our AppFunctions module here
+- **Application class** (`MeshUtilApplication`) already implements `Configuration.Provider` β€” we'll add `AppFunctionConfiguration.Provider`
+- **`CommandSender.sendData(DataPacket)`** is the method to send messages
+- **`DataPacket`** uses `channel: Int` (index) and `to: String?` (nodeID or `ID_BROADCAST`)
+- **`NodeRepository`** has `nodeDBbyNum: StateFlow<Map<Int, Node>>` and `getNodes()` with filter
+- **`ServiceRepository.connectionState: StateFlow<ConnectionState>`** for connection status
+
+## Implementation Phases
+
+### Phase 1: Dependencies & Build Setup
+
+**Files to modify:**
+- `gradle/libs.versions.toml` β€” add AppFunctions library versions
+- `androidApp/build.gradle.kts` β€” apply KSP plugin, add AppFunctions dependencies
+
+**Details:**
+```toml
+# libs.versions.toml
+appfunctions = "1.0.0-alpha09"
+
+# libraries
+androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions", version.ref = "appfunctions" }
+androidx-appfunctions-service = { group = "androidx.appfunctions", name = "appfunctions-service", version.ref = "appfunctions" }
+androidx-appfunctions-compiler = { group = "androidx.appfunctions", name = "appfunctions-compiler", version.ref = "appfunctions" }
+```
+
+In `androidApp/build.gradle.kts`:
+- Apply `com.google.devtools.ksp` plugin
+- Add `implementation(libs.androidx.appfunctions)` and `implementation(libs.androidx.appfunctions.service)`
+- Add `ksp(libs.androidx.appfunctions.compiler)`
+- Add `ksp { arg("appfunctions:aggregateAppFunctions", "true") }`
+
+---
+
+### Phase 2: commonMain Contracts & Utilities (`core/data`)
+
+**New files:**
+- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt`
+- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt`
+- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt`
+- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/RateLimiter.kt`
+- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt`
+- `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt`
+- `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/RateLimiterTest.kt`
+- `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt`
+
+**AiFunctionProvider interface:**
+```kotlin
+package org.meshtastic.core.data.ai
+
+interface AiFunctionProvider {
+ /** Send a text message to a channel or node resolved by name. */
+ suspend fun sendMessage(text: String, recipientName: String?, channelName: String?): SendMessageResult
+
+ /** Get current mesh network status. */
+ suspend fun getMeshStatus(): MeshStatusResult
+}
+```
+
+**AiFunctionResult sealed types:**
+```kotlin
+sealed class SendMessageResult {
+ data class Success(val messageId: Int, val channel: String, val timestamp: Long) : SendMessageResult()
+ data class NotConnected(val message: String) : SendMessageResult()
+ data class AmbiguousName(val candidates: List<String>) : SendMessageResult()
+ data class InvalidArgument(val reason: String) : SendMessageResult()
+ data class RateLimited(val retryAfterSeconds: Int) : SendMessageResult()
+}
+
+data class MeshStatusResult(
+ val connectionState: String,
+ val onlineNodeCount: Int,
+ val totalNodeCount: Int,
+ val localBatteryLevel: Int?,
+ val localNodeName: String?,
+)
+```
+
+**FuzzyNameResolver:**
+- Takes a query string and a list of candidate names
+- Uses longest common substring for matching
+- Returns: single match (exact or unique fuzzy) or error with candidate list
+- Case-insensitive comparison
+- Also resolves channel names from `RadioConfigRepository` channel set
+
+**RateLimiter:**
+- Sliding window: tracks last 5 invocation timestamps, rejects if all within 60s
+- Uses `kotlinx.datetime.Clock` (or injected `Clock` from existing `CoreDataModule`)
+- Thread-safe via `Mutex` (already used in project for commonMain concurrency)
+
+**AiFunctionProviderImpl:**
+- `@Single` Koin annotation
+- Constructor-injects: `NodeRepository`, `ServiceRepository`, `CommandSender`, `RadioConfigRepository`, `FuzzyNameResolver`, `RateLimiter`, `Clock`
+- `sendMessage`: check connection β†’ check rate β†’ resolve name β†’ validate length β†’ create `DataPacket` β†’ `commandSender.sendData()` β†’ return success
+- `getMeshStatus`: read `connectionState.value`, `onlineNodeCount.first()`, `totalNodeCount.first()`, `ourNodeInfo.value?.batteryLevel`
+
+---
+
+### Phase 3: Android App Function Declarations (Google flavor)
+
+**New files:**
+- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/MeshtasticAppFunctions.kt`
+- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/models/SendMessageResponse.kt`
+- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/models/MeshStatusResponse.kt`
+- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/di/AppFunctionsModule.kt`
+
+**Modify:**
+- `androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt` β€” include `AppFunctionsModule`
+- `androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt` β€” add `AppFunctionConfiguration.Provider`
+
+**MeshtasticAppFunctions:**
+```kotlin
+@Suppress("unused") // Invoked by system via AppFunctionManager
+class MeshtasticAppFunctions(
+ private val provider: AiFunctionProvider
+) {
+ /**
+ * Send a text message over the Meshtastic mesh network.
+ *
+ * Messages are broadcast to all nodes on a channel, or sent directly to a
+ * specific node. The recipient is resolved by name using fuzzy matching.
+ *
+ * @param appFunctionContext The execution context provided by the system.
+ * @param text The message text to send (max 237 characters for standard mesh).
+ * @param recipientName Optional node name for direct messages. Omit for channel broadcast.
+ * @param channelName Optional channel name to send on. Defaults to primary channel if omitted.
+ * @return Confirmation with message ID, channel name, and send timestamp.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun sendMessage(
+ appFunctionContext: AppFunctionContext,
+ text: String,
+ recipientName: String? = null,
+ channelName: String? = null,
+ ): SendMessageResponse { ... }
+
+ /**
+ * Get the current status of the Meshtastic mesh network.
+ *
+ * Returns connection state, number of online and total nodes in the mesh,
+ * local device battery level, and the local node's display name.
+ *
+ * @param appFunctionContext The execution context provided by the system.
+ * @return Current mesh network status summary.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getMeshStatus(
+ appFunctionContext: AppFunctionContext,
+ ): MeshStatusResponse { ... }
+}
+```
+
+**AppFunctionConfiguration.Provider** in Application:
+```kotlin
+// In MeshUtilApplication (or subclass in google flavor)
+override val appFunctionConfiguration: AppFunctionConfiguration
+ get() = AppFunctionConfiguration.Builder()
+ .addEnclosingClassFactory(MeshtasticAppFunctions::class.java) {
+ MeshtasticAppFunctions(get<AiFunctionProvider>())
+ }
+ .build()
+```
+
+**Note**: Since the Application class is in `src/main/` (shared), but `AppFunctionConfiguration.Provider` is Android 16+, we need to handle this carefully. Options:
+1. Make google-flavor `GoogleMeshUtilApplication` extend `MeshUtilApplication` and add the provider there
+2. Use a conditional check in the base class
+
+**Decision**: Use option 1 β€” a `GoogleMeshUtilApplication` subclass in the Google flavor that adds `AppFunctionConfiguration.Provider`. This keeps the base class clean and the fdroid flavor unaffected.
+
+---
+
+### Phase 4: Error Mapping
+
+In `MeshtasticAppFunctions`, map `AiFunctionResult` sealed types to platform exceptions:
+- `SendMessageResult.NotConnected` β†’ `AppFunctionAppException("Not connected...")`
+- `SendMessageResult.AmbiguousName` β†’ `AppFunctionInvalidArgumentException("Multiple matches: ...")`
+- `SendMessageResult.InvalidArgument` β†’ `AppFunctionInvalidArgumentException(...)`
+- `SendMessageResult.RateLimited` β†’ `AppFunctionLimitExceededException(...)`
+
+---
+
+### Phase 5: Testing & Verification
+
+1. **Unit tests** (commonMain):
+ - `FuzzyNameResolverTest` β€” exact, fuzzy, ambiguous, no-match cases
+ - `RateLimiterTest` β€” permits, exhaustion, refill
+ - `AiFunctionProviderImplTest` β€” happy path, disconnected, rate limited, ambiguous
+
+2. **Build verification**:
+ - `./gradlew :core:data:allTests`
+ - `./gradlew :androidApp:assembleGoogleDebug`
+ - `./gradlew spotlessApply detekt`
+ - `./gradlew test allTests`
+
+3. **On-device verification** (manual):
+ - `adb shell cmd app_function list-app-functions | grep org.meshtastic`
+
+## Risk Assessment
+
+| Risk | Mitigation |
+|------|-----------|
+| AppFunctions alpha library has breaking API changes | Pin to `1.0.0-alpha09`; isolate behind our own interface |
+| KSP plugin conflicts with existing Koin compiler | KSP and Koin compiler are independent; Koin uses its own Gradle plugin |
+| `AppFunctionConfiguration.Provider` on Application conflicts with `Configuration.Provider` | Use flavor subclass approach |
+| Rate limiter state lost on process death | Acceptable β€” resets on app restart; mesh flooding concern is per-session |
+| Fuzzy matching too permissive/restrictive | Tunable threshold; start conservative (require β‰₯50% substring match) |
+
+## File Change Summary
+
+| Action | File |
+|--------|------|
+| Modify | `gradle/libs.versions.toml` |
+| Modify | `androidApp/build.gradle.kts` |
+| Create | `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt` |
+| Create | `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt` |
+| Create | `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt` |
+| Create | `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/RateLimiter.kt` |
+| Create | `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt` |
+| Create | `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt` |
+| Create | `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/RateLimiterTest.kt` |
+| Create | `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt` |
+| Create | `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/MeshtasticAppFunctions.kt` |
+| Create | `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/models/SendMessageResponse.kt` |
+| Create | `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/models/MeshStatusResponse.kt` |
+| Create | `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/di/AppFunctionsModule.kt` |
+| Modify | `androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt` |
+| Create or Modify | `androidApp/src/google/kotlin/org/meshtastic/app/GoogleMeshUtilApplication.kt` |
+| Modify | `androidApp/src/google/AndroidManifest.xml` (point to GoogleMeshUtilApplication) |

diff --git a/specs/20260521-091500-app-functions/spec.md b/specs/20260521-091500-app-functions/spec.md
new file mode 100644
index 0000000000..13f17a2b91
--- /dev/null
+++ b/specs/20260521-091500-app-functions/spec.md
@@ -0,0 +1,313 @@
+# Feature Specification: Android App Functions Integration
+
+**Feature Branch**: `jamesarich/crispy-barnacle`
+**Created**: 2026-05-21
+**Status**: Draft
+**Input**: User description: "Set up App Functions so our app can integrate better with system AI"
+**Cross-Platform Spec**: KMP β€” interfaces defined in commonMain; Android implementation in androidApp (Google flavor first-class)
+
+## Summary
+
+Expose key Meshtastic capabilities as [Android App Functions](https://developer.android.com/ai/appfunctions) so system AI assistants (Gemini, etc.) can discover and invoke them on behalf of the user. App Functions act as on-device MCP tools, letting users interact with the mesh network through natural language β€” sending messages and checking mesh health β€” without manually navigating the app UI.
+
+**Phase 1 (this spec)** focuses on a minimal MVP of 2 functions (`sendMessage` + `getMeshStatus`) to validate the integration end-to-end. Additional functions (listNodes, getRecentMessages, getNodePosition, waypoints, traceroute) will be added in Phase 2 after validation.
+
+## Goals
+
+1. Declare a minimal set of App Functions that validate the Meshtastic ↔ system AI integration pattern
+2. Enable natural-language interactions like "Send a message to the mesh" or "How many nodes are online?"
+3. Follow Android App Functions best practices: KDoc-described functions, `@AppFunctionSerializable` models, and proper `AppFunctionContext` usage
+4. Define platform-agnostic interfaces in `commonMain` so other platforms (Desktop, iOS) can expose equivalent capabilities through their own AI systems in the future
+5. Implement the Android-specific `@AppFunction` annotations in `androidApp` (Google flavor first-class)
+6. Integrate with existing Koin DI to resolve repositories and managers
+
+## Non-Goals
+
+- Implementing a remote MCP server (App Functions are on-device only)
+- Exposing radio configuration or admin operations to AI (security-sensitive; future consideration)
+- Building custom AI/LLM features within the app itself
+- Handling firmware updates or device provisioning through AI
+- Exposing raw protobuf operations or low-level radio commands
+- Phase 2+ functions (listNodes, getRecentMessages, getNodePosition, waypoints, traceroute) β€” deferred until Phase 1 validates the pattern
+- F-Droid flavor implementation (platform API works there, but Google flavor is first-class target)
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Send a Mesh Message via AI (Priority: P1)
+
+As a user talking to my phone's AI assistant, I want to say "Send a message to the mesh saying I'll be at the trailhead in 30 minutes" so I can communicate without opening the app.
+
+**Why this priority**: Messaging is the #1 use case for Meshtastic; it's the most natural AI-triggered action.
+
+**Independent Test**: Can be verified by invoking the App Function through the test agent app and confirming the message appears in the mesh message list.
+
+**Acceptance Scenarios**:
+
+1. **Given** the device is connected to a Meshtastic radio, **When** the AI assistant invokes `sendMessage` with text and a channel name, **Then** the channel is resolved via fuzzy matching, the message is transmitted over the mesh, and a confirmation with message ID is returned
+2. **Given** the device is NOT connected to a radio, **When** the AI invokes `sendMessage`, **Then** the function returns an error result indicating no active connection
+3. **Given** a valid node name is provided as the recipient, **When** the AI invokes `sendMessage` with a direct message target, **Then** the node name is fuzzy-matched and the message is sent as a DM to that specific node
+4. **Given** the AI invokes `sendMessage` more than 5 times within 60 seconds, **When** the rate limit is exceeded, **Then** `AppFunctionLimitExceededException` is thrown with a descriptive message
+5. **Given** a node name matches multiple nodes (e.g., "Jake" β†’ "Jake's Radio" + "Jake_hiking"), **When** the match is ambiguous, **Then** `AppFunctionInvalidArgumentException` is thrown listing the candidate names for the AI to disambiguate
+
+---
+
+### User Story 2 - Query Mesh Network Status (Priority: P1)
+
+As a user, I want to ask my AI assistant "How's my mesh network doing?" and get a summary of online nodes, my node's battery, and connection state.
+
+**Why this priority**: Status queries are read-only and safe β€” ideal first-class AI capabilities.
+
+**Independent Test**: Can be verified by invoking `getMeshStatus` and confirming the returned data matches the app's node list.
+
+**Acceptance Scenarios**:
+
+1. **Given** the device is connected, **When** the AI invokes `getMeshStatus`, **Then** it returns online node count, total node count, local battery level, and connection state
+2. **Given** the device is disconnected, **When** the AI invokes `getMeshStatus`, **Then** it returns the disconnected state with last-known node counts
+
+---
+
+### Edge Cases
+
+- What happens when multiple nodes match a name query? Return `AppFunctionInvalidArgumentException` listing candidate names so the AI agent can ask the user to clarify.
+- What happens when multiple channels match a name query? Same approach β€” return candidates for disambiguation.
+- What happens when the radio connection drops mid-operation? Return an error result; do not crash or hang.
+- What happens with very long messages? Enforce the Meshtastic message length limit (237 bytes for standard, longer for PKC) and return `AppFunctionInvalidArgumentException` if exceeded.
+- What happens if the rate limit is hit? Throw `AppFunctionLimitExceededException`; the AI agent handles this gracefully per platform conventions.
+
+## Architecture
+
+### Key Components
+
+| Component | Module / File | Purpose |
+|-----------|---------------|---------|
+| AiFunctionProvider (interface) | `core/data/src/commonMain/.../ai/AiFunctionProvider.kt` | Platform-agnostic contract defining operations exposable to AI systems |
+| MeshtasticAppFunctions | `androidApp/src/main/kotlin/.../appfunctions/MeshtasticAppFunctions.kt` | `@AppFunction`-annotated Android implementation |
+| AppFunctionModels | `androidApp/src/main/kotlin/.../appfunctions/models/` | `@AppFunctionSerializable` data classes for function inputs/outputs |
+| FuzzyNameResolver | `core/data/src/commonMain/.../ai/FuzzyNameResolver.kt` | Fuzzy matching for node and channel names (longest-substring, error if ambiguous) |
+| RateLimiter | `core/data/src/commonMain/.../ai/RateLimiter.kt` | Sliding-window rate limiter (5 calls / 60s) for send operations |
+| NodeRepository | `core/repository/` (commonMain) | Existing node data access β€” unchanged |
+| PacketRepository | `core/repository/` (commonMain) | Existing message data access β€” unchanged |
+| ServiceRepository | `core/repository/` (commonMain) | Existing connection state β€” unchanged |
+| CommandSender | `core/repository/` (commonMain) | Existing mesh command dispatch β€” unchanged |
+
+### Data Flow
+
+```
+System AI Agent (Gemini)
+ ↓ (EXECUTE_APP_FUNCTIONS permission)
+AppFunctionManager (Android OS, API 35+)
+ ↓
+MeshtasticAppFunctions (@AppFunction annotated, androidApp)
+ ↓
+AiFunctionProvider interface (commonMain contract)
+ ↓
+FuzzyNameResolver β†’ NodeRepository / RadioConfigRepository (name β†’ ID resolution)
+ ↓
+CommandSender / ServiceRepository (execute operation)
+ ↓
+MeshServiceOrchestrator β†’ Radio
+```
+
+### Dependency Graph
+
+```mermaid
+graph TD
+ A[System AI / Gemini] -->|AppFunctionManager| B[MeshtasticAppFunctions]
+ B --> C[AiFunctionProvider impl]
+ C --> D[FuzzyNameResolver]
+ C --> E[RateLimiter]
+ D --> F[NodeRepository]
+ D --> G[RadioConfigRepository]
+ C --> H[ServiceRepository]
+ C --> I[CommandSender]
+ H --> J[MeshServiceOrchestrator]
+ I --> J
+ J --> K[RadioInterfaceService]
+```
+
+### KMP Architecture Pattern
+
+```
+commonMain/
+β”œβ”€β”€ ai/
+β”‚ β”œβ”€β”€ AiFunctionProvider.kt # Interface: what operations AI can invoke
+β”‚ β”œβ”€β”€ AiFunctionResult.kt # Sealed result types (success/error)
+β”‚ β”œβ”€β”€ FuzzyNameResolver.kt # Name matching logic (testable, shared)
+β”‚ └── RateLimiter.kt # Token-bucket limiter (testable, shared)
+
+androidApp/ (Google flavor)
+β”œβ”€β”€ appfunctions/
+β”‚ β”œβ”€β”€ MeshtasticAppFunctions.kt # @AppFunction declarations
+β”‚ β”œβ”€β”€ AppFunctionFactory.kt # Koin-based factory for DI
+β”‚ └── models/ # @AppFunctionSerializable types
+```
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: The app MUST declare App Functions using the `@AppFunction(isDescribedByKDoc = true)` annotation with comprehensive KDoc descriptions
+- **FR-002**: All function parameters and return types MUST use `@AppFunctionSerializable` data classes with KDoc-described fields
+- **FR-003**: `sendMessage` MUST resolve the destination via fuzzy name matching (channel name or node name), transmit the text message, and return a confirmation with message ID
+- **FR-004**: `sendMessage` MUST enforce a rate limit of 5 invocations per 60-second sliding window, throwing `AppFunctionLimitExceededException` when exceeded
+- **FR-005**: `getMeshStatus` MUST return current connection state, online/total node counts, and local device battery level
+- **FR-006**: Fuzzy name matching MUST use longest-substring matching and throw `AppFunctionInvalidArgumentException` with candidate names when ambiguous
+- **FR-007**: All functions MUST gracefully handle the disconnected state by throwing appropriate `AppFunctionException` subclasses (not generic exceptions)
+- **FR-008**: The `sendMessage` function MUST validate message length against the Meshtastic protocol limit before transmission
+- **FR-009**: A platform-agnostic `AiFunctionProvider` interface MUST be defined in `commonMain` with the operation contracts
+- **FR-010**: The Android implementation MUST resolve dependencies through Koin via a custom `AppFunctionConfiguration.Provider`
+- **FR-011**: `sendMessage` MUST send immediately without a confirmation dialog (AI invocation implies user intent per platform guidelines)
+
+### Non-Functional Requirements
+
+- **NFR-001**: App Functions MUST NOT expose sensitive configuration (admin channels, encryption keys, radio settings) to AI agents
+- **NFR-002**: All App Function operations MUST complete within 5 seconds or throw a timeout error
+- **NFR-003**: The App Functions layer requires `compileSdk` 36+ and MUST only be active on devices running Android 16 (API 35+)
+- **NFR-004**: KDoc descriptions MUST be clear enough for an AI agent to understand the function's purpose without additional context
+- **NFR-005**: The `AiFunctionProvider` interface in commonMain MUST have no Android dependencies
+- **NFR-006**: Dependencies: `androidx.appfunctions:appfunctions:1.0.0-alpha09`, `appfunctions-service:1.0.0-alpha09`, `appfunctions-compiler:1.0.0-alpha09` (KSP)
+
+## Source-Set Impact
+
+| Source Set | Impact | Justification |
+|-----------|--------|---------------|
+| `commonMain` (core/data) | New: `AiFunctionProvider` interface, `FuzzyNameResolver`, `RateLimiter`, result types | Platform-agnostic contracts and shared logic |
+| `androidMain` (androidApp, Google flavor) | New: `@AppFunction` declarations, serializable models, Koin factory | AppFunctions is an Android platform API |
+| `jvmMain` | None | Desktop not affected (future: could implement via local MCP server) |
+| `iosMain` | None | iOS not affected (future: could implement via App Intents) |
+
+## Design Standards Compliance
+
+- [x] New screens reviewed against design standards β€” N/A, no UI changes
+- [x] M3 component selection verified β€” N/A, no UI components
+- [x] Accessibility: N/A β€” App Functions are invoked by AI, not direct user interaction
+- [x] Typography: N/A
+- [x] KDoc documentation provides clear natural-language descriptions for AI discovery
+
+## Privacy Assessment
+
+- [x] No PII logged or exposed beyond what the user has already shared on the mesh
+- [x] Position data gated behind existing privacy settings
+- [x] Messages sent only with user's implicit consent (AI assistant invocation = user intent)
+- [x] No encryption keys, admin channel configs, or radio settings exposed
+- [x] Proto submodule (`core/proto`) not modified (read-only upstream)
+- [x] `EXECUTE_APP_FUNCTIONS` permission required by callers β€” only authorized system agents can invoke
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: Both declared App Functions are discoverable via `adb shell cmd app_function list-app-functions | grep org.meshtastic` on API 35+ devices
+- **SC-002**: The test agent app can successfully invoke `sendMessage` and receive a confirmation with message ID when connected
+- **SC-003**: The test agent app can invoke `getMeshStatus` and receive accurate node counts matching the app's UI within 1 second
+- **SC-004**: `sendMessage` returns `AppFunctionLimitExceededException` after 5 rapid invocations within 60 seconds
+- **SC-005**: Ambiguous name queries return `AppFunctionInvalidArgumentException` with candidate list
+- **SC-006**: Zero crashes or ANRs introduced by App Function invocations
+
+## Decisions Log
+
+| # | Question | Decision | Rationale |
+|---|----------|----------|-----------|
+| 1 | User confirmation before sending? | No β€” send immediately | AI invocation implies user intent per platform guidelines; messaging is additive not destructive |
+| 2 | Rate limiting? | Yes β€” 5 messages/60s sliding window | Aligns with radio duty-cycle constraints; platform provides `AppFunctionLimitExceededException` |
+| 3 | Channel/node selection? | Fuzzy name matching | More natural for AI conversation; longest-substring match with error on ambiguity |
+| 4 | Build flavor scope? | Google flavor first-class, KMP interfaces in commonMain | Platform API works everywhere but Gemini (primary caller) is Google; KMP future-proofs |
+| 5 | Initial scope? | Minimal MVP (2 functions) | Validate integration pattern before expanding; sendMessage + getMeshStatus |
+
+## Assumptions
+
+- The user has already paired and connected to a Meshtastic radio device
+- The app is installed on an Android 16+ (API 35) device that supports App Functions
+- System AI agents have the `EXECUTE_APP_FUNCTIONS` permission (granted by the OS)
+- The Jetpack AppFunctions library (`androidx.appfunctions:appfunctions-*` 1.0.0-alpha09) is stable enough for integration
+- Koin dependency injection context is available when App Functions are invoked (app process is alive)
+- The AppFunctions annotation processor (KSP) is compatible with the project's existing KSP setup
+- `compileSdk` is already 37 (satisfies the β‰₯36 requirement for AppFunctions library)
+
+## Open Questions
+
+1. **Exact rate limit values**: Is 5 messages/60 seconds the right threshold, or should it align with a specific radio duty-cycle calculation?
+2. **Background invocation**: Can App Functions be invoked when the app is in the background but the service is running? (Likely yes, since `AppFunctionService` runs in the app process)
+
+## Future Considerations (Phase 3+)
+
+- **getNodePosition**: "Where is Jake?" β†’ return GPS coordinates (gated by privacy settings)
+- **Waypoint management**: Create/delete waypoints via AI
+- **Traceroute**: "Can I reach node X?" β†’ invoke traceroute and return hop count
+- **Location sharing**: "Share my location on the mesh" β†’ trigger position broadcast
+- **requestTraceroute**: Non-destructive route diagnostic via fuzzy node name
+- **sendQuickChat**: Voice-triggered pre-configured message shortcuts
+- **findNearbyNodes**: Location-aware proximity query sorted by distance
+- **requestNodePosition**: Ask a specific node to share its GPS coordinates
+- **Desktop/iOS parity**: Implement `AiFunctionProvider` via local MCP server (Desktop) or App Intents (iOS)
+
+---
+
+## Phase 3: Message History Functions
+
+### User Story 6 - Read Recent Messages via AI (Priority: P1)
+
+As a user returning from an activity, I want to ask "What messages did I miss?" and get a summary of recent mesh messages without opening the app.
+
+**Why this priority**: "Catch me up" is the #1 voice query pattern for communication apps. Mesh messages arrive asynchronously during hikes/outdoor activities where the phone is pocketed.
+
+**Independent Test**: Invoke `getRecentMessages` and confirm returned messages match the app's message list.
+
+**Acceptance Scenarios**:
+
+1. **Given** the device is connected and messages exist, **When** the AI invokes `getRecentMessages` without filters, **Then** the most recent messages (up to limit) are returned with sender name, text, channel, and timestamp
+2. **Given** a contactName is provided, **When** the AI invokes `getRecentMessages` with that filter, **Then** only messages from that contact/channel are returned
+3. **Given** no messages exist, **When** the AI invokes `getRecentMessages`, **Then** an empty list is returned (not an error)
+4. **Given** the device is disconnected, **When** the AI invokes `getRecentMessages`, **Then** cached messages are still returned (message history is local)
+
+---
+
+### User Story 7 - Unread Message Summary via AI (Priority: P1)
+
+As a user, I want to ask "Do I have unread messages?" and get a per-contact breakdown showing who messaged me and a preview of their last message.
+
+**Why this priority**: Unread summaries let users decide whether to open the app, reducing unnecessary screen time during outdoor activities.
+
+**Independent Test**: Invoke `getUnreadSummary` and confirm counts match the app's contact list badges.
+
+**Acceptance Scenarios**:
+
+1. **Given** unread messages exist from multiple contacts, **When** the AI invokes `getUnreadSummary`, **Then** the total unread count and per-contact breakdown (name, unread count, last message preview) are returned
+2. **Given** no unread messages exist, **When** the AI invokes `getUnreadSummary`, **Then** totalUnreadCount is 0 and the contacts list is empty
+3. **Given** a contact has been muted, **When** the AI invokes `getUnreadSummary`, **Then** muted contacts are excluded from the breakdown
+
+---
+
+### Functional Requirements (Phase 3)
+
+- **FR-012**: `getRecentMessages` MUST return recent messages sorted newest-first, limited to a configurable count (default 20, max 50)
+- **FR-013**: `getRecentMessages` MUST support optional `contactName` filter using the existing `FuzzyNameResolver`
+- **FR-014**: `getRecentMessages` MUST NOT require an active radio connection (messages are cached locally)
+- **FR-015**: `getUnreadSummary` MUST return total unread count and per-contact breakdown with last message preview
+- **FR-016**: `getUnreadSummary` MUST exclude muted contacts from the breakdown
+- **FR-017**: Both functions MUST respect the 5-second timeout constraint (NFR-002)
+
+### Architecture Addition
+
+```
+commonMain/ai/
+β”œβ”€β”€ AiFunctionProvider.kt # + getRecentMessages(), getUnreadSummary()
+β”œβ”€β”€ AiFunctionResult.kt # + GetRecentMessagesResult, GetUnreadSummaryResult
+
+androidApp/ (Google flavor)
+β”œβ”€β”€ appfunctions/
+β”‚ β”œβ”€β”€ MeshtasticAppFunctions.kt # + getRecentMessages, getUnreadSummary
+β”‚ └── AppFunctionModels.kt # + MessageInfo, UnreadSummaryResponse, ContactUnreadInfo
+```
+
+### Data Flow (Message History)
+
+```
+AiFunctionProvider.getRecentMessages()
+ ↓
+PacketRepository.getMessagesFrom() / getContacts()
+ ↓
+NodeRepository (resolve sender names)
+ ↓
+Return MessageInfo list
+```

diff --git a/specs/20260521-091500-app-functions/tasks.md b/specs/20260521-091500-app-functions/tasks.md
new file mode 100644
index 0000000000..8e9ecf4162
--- /dev/null
+++ b/specs/20260521-091500-app-functions/tasks.md
@@ -0,0 +1,349 @@
+# Tasks: Android App Functions Integration
+
+**Spec**: `specs/20260521-091500-app-functions/spec.md`
+**Plan**: `specs/20260521-091500-app-functions/plan.md`
+**Branch**: `jamesarich/crispy-barnacle`
+
+## Task Dependency Graph
+
+```mermaid
+graph TD
+ T1[T1: Add dependencies] --> T2[T2: AiFunctionProvider interface]
+ T1 --> T3[T3: Result types]
+ T2 --> T4[T4: FuzzyNameResolver]
+ T3 --> T4
+ T2 --> T5[T5: RateLimiter]
+ T3 --> T5
+ T4 --> T6[T6: AiFunctionProviderImpl]
+ T5 --> T6
+ T6 --> T7[T7: Unit tests - commonMain]
+ T6 --> T8[T8: AppFunction models]
+ T8 --> T9[T9: MeshtasticAppFunctions class]
+ T9 --> T10[T10: DI & Application wiring]
+ T10 --> T11[T11: Build verification]
+ T7 --> T11
+```
+
+---
+
+## T1: Add AppFunctions Dependencies & KSP Plugin
+
+**Priority**: P0 (blocking)
+**Depends on**: None
+**Estimated effort**: Small
+
+### Description
+Add the `androidx.appfunctions` library suite to the version catalog and configure KSP in `androidApp`.
+
+### Files to modify
+- `gradle/libs.versions.toml` β€” add version + 3 library entries
+- `androidApp/build.gradle.kts` β€” apply KSP plugin, add dependencies, add KSP arg
+
+### Acceptance criteria
+- [ ] `./gradlew :androidApp:dependencies | grep appfunctions` shows all 3 artifacts resolved
+- [ ] `./gradlew :androidApp:compileGoogleDebugKotlin` compiles without errors
+
+### Implementation notes
+```toml
+# In [versions]
+appfunctions = "1.0.0-alpha09"
+
+# In [libraries]
+androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions", version.ref = "appfunctions" }
+androidx-appfunctions-service = { group = "androidx.appfunctions", name = "appfunctions-service", version.ref = "appfunctions" }
+androidx-appfunctions-compiler = { group = "androidx.appfunctions", name = "appfunctions-compiler", version.ref = "appfunctions" }
+```
+
+In `androidApp/build.gradle.kts`:
+- Add `alias(libs.plugins.ksp)` to plugins block (verify KSP plugin alias exists in catalog)
+- Add `ksp { arg("appfunctions:aggregateAppFunctions", "true") }` block
+- Add `implementation(libs.androidx.appfunctions)`
+- Add `implementation(libs.androidx.appfunctions.service)`
+- Add `ksp(libs.androidx.appfunctions.compiler)`
+
+---
+
+## T2: Create AiFunctionProvider Interface
+
+**Priority**: P0 (blocking)
+**Depends on**: T1
+**Estimated effort**: Small
+
+### Description
+Define the platform-agnostic interface in `core/data` commonMain that declares what operations AI systems can invoke.
+
+### Files to create
+- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt`
+
+### Acceptance criteria
+- [ ] Interface has `sendMessage` and `getMeshStatus` suspend functions
+- [ ] No `android.*` or `java.*` imports
+- [ ] `./gradlew :core:data:compileKotlinJvm` passes
+
+### Implementation notes
+```kotlin
+package org.meshtastic.core.data.ai
+
+interface AiFunctionProvider {
+ suspend fun sendMessage(text: String, recipientName: String?, channelName: String?): SendMessageResult
+ suspend fun getMeshStatus(): MeshStatusResult
+}
+```
+
+---
+
+## T3: Create Result Types (AiFunctionResult.kt)
+
+**Priority**: P0 (blocking)
+**Depends on**: T1
+**Estimated effort**: Small
+
+### Description
+Define sealed result types for AI function operations. These are pure data classes with no platform dependencies.
+
+### Files to create
+- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt`
+
+### Acceptance criteria
+- [ ] `SendMessageResult` sealed class with `Success`, `NotConnected`, `AmbiguousName`, `InvalidArgument`, `RateLimited` variants
+- [ ] `MeshStatusResult` data class with `connectionState`, `onlineNodeCount`, `totalNodeCount`, `localBatteryLevel`, `localNodeName`
+- [ ] No platform dependencies
+- [ ] Compiles on all targets: `./gradlew :core:data:compileKotlinJvm`
+
+---
+
+## T4: Implement FuzzyNameResolver
+
+**Priority**: P0 (blocking)
+**Depends on**: T2, T3
+**Estimated effort**: Medium
+
+### Description
+Implement longest-substring fuzzy name matching for resolving node names and channel names. Case-insensitive. Returns single match or error with candidates.
+
+### Files to create
+- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt`
+
+### Acceptance criteria
+- [ ] Exact match (case-insensitive) returns immediately
+- [ ] Unique fuzzy match (longest common substring β‰₯ 50% of query length) returns the match
+- [ ] Multiple fuzzy matches returns `AmbiguousName` with candidate list
+- [ ] No match returns empty/not-found
+- [ ] `@Single` Koin annotation for DI registration
+- [ ] Resolves node names from `NodeRepository.nodeDBbyNum`
+- [ ] Resolves channel names from `RadioConfigRepository` channel list
+- [ ] Admin channels excluded from resolution results (NFR-001: no sensitive config exposed)
+
+### Implementation notes
+- Constructor injects `NodeRepository` and `RadioConfigRepository`
+- `resolveNodeName(query: String): NodeNameResult` β†’ sealed: `Found(nodeNum, userId)`, `Ambiguous(candidates)`, `NotFound`
+- `resolveChannelName(query: String): ChannelNameResult` β†’ sealed: `Found(channelIndex, name)`, `Ambiguous(candidates)`, `NotFound`
+- Longest Common Substring algorithm for fuzzy scoring
+
+---
+
+## T5: Implement RateLimiter
+
+**Priority**: P0 (blocking)
+**Depends on**: T2, T3
+**Estimated effort**: Small
+
+### Description
+Sliding-window rate limiter: tracks last 5 invocation timestamps within a 60-second window. Thread-safe via Mutex.
+
+### Files to create
+- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/RateLimiter.kt`
+
+### Acceptance criteria
+- [ ] Permits up to 5 calls within 60 seconds
+- [ ] Returns `RateLimited(retryAfterSeconds)` when all 5 slots are within the window
+- [ ] Thread-safe (Mutex)
+- [ ] Uses injected `Clock` for testability
+- [ ] `@Single` Koin annotation
+
+### Implementation notes
+```kotlin
+@Single
+class RateLimiter(private val clock: Clock) {
+ private val mutex = Mutex()
+ private val maxCalls = 5
+ private val windowDuration = 60.seconds
+ private val timestamps = ArrayDeque<Instant>(maxCalls)
+
+ suspend fun tryAcquire(): RateLimitResult { ... }
+}
+
+sealed class RateLimitResult {
+ data object Permitted : RateLimitResult()
+ data class Limited(val retryAfterSeconds: Int) : RateLimitResult()
+}
+```
+
+---
+
+## T6: Implement AiFunctionProviderImpl
+
+**Priority**: P0 (blocking)
+**Depends on**: T4, T5
+**Estimated effort**: Medium
+
+### Description
+Wire the AI function interface to existing repositories. This is the core business logic bridge.
+
+### Files to create
+- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt`
+
+### Acceptance criteria
+- [ ] `@Single` Koin annotation, binds `AiFunctionProvider` interface
+- [ ] `sendMessage` flow: check connection β†’ rate limit β†’ resolve name β†’ validate length β†’ create DataPacket β†’ send β†’ return Success
+- [ ] `getMeshStatus` flow: read connectionState, node counts, battery, node name
+- [ ] Disconnected state returns `NotConnected` (not exception)
+- [ ] Message length validated against 237-byte limit
+- [ ] All operations complete within timeout (use `withTimeout(5.seconds)`)
+
+### Implementation notes
+- Inject: `NodeRepository`, `ServiceRepository`, `CommandSender`, `RadioConfigRepository`, `FuzzyNameResolver`, `RateLimiter`
+- For `sendMessage`: construct `DataPacket(to = resolvedNodeId, bytes = text.encodeToByteString(), dataType = Portnums.TEXT_MESSAGE_APP, channel = resolvedChannelIndex)`
+- For `getMeshStatus`: use `.value` on StateFlows (no suspension needed for connection state), `.first()` for counts
+- `ConnectionState.CONNECTED` check before proceeding
+
+---
+
+## T7: Unit Tests for commonMain AI Layer
+
+**Priority**: P1
+**Depends on**: T6
+**Estimated effort**: Medium
+
+### Description
+Comprehensive unit tests for FuzzyNameResolver, RateLimiter, and AiFunctionProviderImpl.
+
+### Files to create
+- `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt`
+- `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/RateLimiterTest.kt`
+- `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt`
+
+### Acceptance criteria
+- [ ] **FuzzyNameResolverTest**: exact match, unique fuzzy, ambiguous, no match, case insensitivity, channel name resolution, channel ambiguity
+- [ ] **FuzzyNameResolverTest (security)**: admin channels excluded from resolution results (NFR-001)
+- [ ] **RateLimiterTest**: permits under limit, blocks at limit, refills after window expires (use fake Clock)
+- [ ] **AiFunctionProviderImplTest**: happy path send, disconnected error, rate limited, ambiguous name, message too long, getMeshStatus connected, getMeshStatus disconnected
+- [ ] **AiFunctionProviderImplTest (timeout)**: verify operations throw timeout after 5 seconds when repository hangs (NFR-002)
+- [ ] All tests pass: `./gradlew :core:data:allTests`
+
+### Implementation notes
+- Use `runTest(UnconfinedTestDispatcher())` for coroutine tests
+- Mock repositories with fakes or mockk
+- Inject fake `Clock` that can be advanced for rate limiter tests
+
+---
+
+## T8: Create AppFunction Serializable Models
+
+**Priority**: P1
+**Depends on**: T6
+**Estimated effort**: Small
+
+### Description
+Define `@AppFunctionSerializable` response types for the Android platform layer.
+
+### Files to create
+- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/models/SendMessageResponse.kt`
+- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/models/MeshStatusResponse.kt`
+
+### Acceptance criteria
+- [ ] Both classes annotated with `@AppFunctionSerializable(isDescribedByKDoc = true)`
+- [ ] All fields have KDoc descriptions clear enough for AI agent understanding
+- [ ] `SendMessageResponse`: messageId (Int), channelName (String), timestamp (Long)
+- [ ] `MeshStatusResponse`: connectionState (String), onlineNodeCount (Int), totalNodeCount (Int), batteryLevel (Int?), localNodeName (String?)
+- [ ] Compiles: `./gradlew :androidApp:compileGoogleDebugKotlin`
+
+---
+
+## T9: Implement MeshtasticAppFunctions Class
+
+**Priority**: P1
+**Depends on**: T8
+**Estimated effort**: Medium
+
+### Description
+Create the `@AppFunction`-annotated class that the Android system discovers and invokes. Maps commonMain results to platform exceptions.
+
+### Files to create
+- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/MeshtasticAppFunctions.kt`
+
+### Acceptance criteria
+- [ ] `sendMessage` annotated with `@AppFunction(isDescribedByKDoc = true)` with comprehensive KDoc
+- [ ] `getMeshStatus` annotated with `@AppFunction(isDescribedByKDoc = true)` with comprehensive KDoc
+- [ ] First param is always `AppFunctionContext`
+- [ ] Error mapping: `NotConnected` β†’ `AppFunctionAppException`, `AmbiguousName` β†’ `AppFunctionInvalidArgumentException`, `RateLimited` β†’ `AppFunctionLimitExceededException`, `InvalidArgument` β†’ `AppFunctionInvalidArgumentException`
+- [ ] Constructor takes `AiFunctionProvider`
+- [ ] Compiles with KSP generating schema
+
+---
+
+## T10: DI Wiring & Application Configuration
+
+**Priority**: P1
+**Depends on**: T9
+**Estimated effort**: Medium
+
+### Description
+Wire AppFunctions into Koin DI and configure the Application class to provide the factory.
+
+### Files to create
+- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/di/AppFunctionsModule.kt`
+- `androidApp/src/google/kotlin/org/meshtastic/app/GoogleMeshUtilApplication.kt`
+
+### Files to modify
+- `androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt` β€” add `AppFunctionsModule` to includes
+- `androidApp/src/google/AndroidManifest.xml` β€” point `android:name` to `GoogleMeshUtilApplication`
+
+### Acceptance criteria
+- [ ] `AppFunctionsModule` provides `MeshtasticAppFunctions` via Koin
+- [ ] `FlavorModule` includes `AppFunctionsModule`
+- [ ] `GoogleMeshUtilApplication` extends `MeshUtilApplication` and implements `AppFunctionConfiguration.Provider`
+- [ ] Google flavor manifest uses `GoogleMeshUtilApplication`
+- [ ] F-Droid flavor unaffected (still uses base `MeshUtilApplication`)
+- [ ] App launches without crash: `./gradlew :androidApp:assembleGoogleDebug`
+
+### Implementation notes
+- `GoogleMeshUtilApplication` overrides `appFunctionConfiguration`:
+ ```kotlin
+ override val appFunctionConfiguration: AppFunctionConfiguration
+ get() = AppFunctionConfiguration.Builder()
+ .addEnclosingClassFactory(MeshtasticAppFunctions::class.java) {
+ get<MeshtasticAppFunctions>()
+ }
+ .build()
+ ```
+- Check if google flavor already has a custom Application subclass
+
+---
+
+## T11: Build Verification & Final Checks
+
+**Priority**: P1
+**Depends on**: T7, T10
+**Estimated effort**: Small
+
+### Description
+Run full verification suite and confirm AppFunctions are properly registered.
+
+### Commands to run
+```bash
+./gradlew spotlessApply
+./gradlew spotlessCheck detekt
+./gradlew assembleDebug
+./gradlew test allTests
+./gradlew :androidApp:assembleGoogleDebug
+./gradlew :androidApp:assembleFdroidDebug
+```
+
+### Acceptance criteria
+- [ ] All formatting passes (`spotlessCheck`)
+- [ ] All static analysis passes (`detekt`)
+- [ ] Both flavors compile (`assembleGoogleDebug`, `assembleFdroidDebug`)
+- [ ] All tests pass (`test allTests`)
+- [ ] No new warnings introduced
+- [ ] KSP generates AppFunction schema XML in build output

Served by rngit 1.5.0 - Generated in 0.36s